diff --git a/.gitlab/build-sidecar.sh b/.gitlab/build-sidecar.sh index bad3171853..b3314c4d8c 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 7eae904efe..b43416e47b 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 7378c7879c..971d6df7be 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 7d190dfa02..e89395e9d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1208,30 +1208,6 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" -[[package]] -name = "datadog-ffe" -version = "1.0.0" -dependencies = [ - "chrono", - "derive_more", - "faststr", - "libdd-common", - "libdd-remote-config", - "libdd-trace-protobuf", - "log", - "lru", - "md5", - "prost", - "pyo3", - "semver", - "serde", - "serde-bool", - "serde_json", - "serde_with", - "thiserror 2.0.18", - "url", -] - [[package]] name = "datadog-ipc" version = "0.1.0" @@ -1293,6 +1269,8 @@ dependencies = [ "serde", "serde_json", "smallvec", + "strum", + "strum_macros", "sys-info", "tokio", "uuid", @@ -1322,7 +1300,6 @@ dependencies = [ "bincode", "cbindgen 0.27.0", "const-str", - "datadog-ffe", "datadog-ipc", "datadog-live-debugger", "datadog-live-debugger-ffi", @@ -1339,6 +1316,8 @@ dependencies = [ "libdd-crashtracker", "libdd-crashtracker-ffi", "libdd-data-pipeline", + "libdd-ffe", + "libdd-library-config", "libdd-library-config-ffi", "libdd-remote-config", "libdd-telemetry", @@ -1384,8 +1363,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", @@ -1411,7 +1392,6 @@ dependencies = [ "bincode", "chrono", "console-subscriber", - "datadog-ffe", "datadog-ipc", "datadog-ipc-macros", "datadog-live-debugger", @@ -1429,6 +1409,7 @@ dependencies = [ "libdd-crashtracker-ffi", "libdd-data-pipeline", "libdd-dogstatsd-client", + "libdd-ffe", "libdd-remote-config", "libdd-telemetry", "libdd-tinybytes", @@ -2769,7 +2750,7 @@ dependencies = [ [[package]] name = "libdd-capabilities" -version = "2.1.0" +version = "3.0.0" dependencies = [ "anyhow", "bytes", @@ -2781,7 +2762,7 @@ dependencies = [ [[package]] name = "libdd-capabilities-impl" -version = "3.0.0" +version = "4.0.0" dependencies = [ "anyhow", "bytes", @@ -2795,7 +2776,7 @@ dependencies = [ [[package]] name = "libdd-common" -version = "5.1.0" +version = "5.1.1" dependencies = [ "anyhow", "bytes", @@ -2820,6 +2801,7 @@ dependencies = [ "multer", "nix 0.29.0", "pin-project", + "proptest", "rand 0.8.5", "regex", "regex-lite", @@ -2956,6 +2938,7 @@ dependencies = [ "tracing", "uuid", "web-time", + "zstd", ] [[package]] @@ -2985,6 +2968,36 @@ dependencies = [ "tracing", ] +[[package]] +name = "libdd-ffe" +version = "1.0.0" +dependencies = [ + "bincode", + "chrono", + "derive_more", + "faststr", + "http", + "httpmock", + "libdd-capabilities", + "libdd-capabilities-impl", + "libdd-common", + "libdd-remote-config", + "libdd-trace-protobuf", + "log", + "lru", + "md5", + "prost", + "pyo3", + "semver", + "serde", + "serde-bool", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "url", +] + [[package]] name = "libdd-library-config" version = "3.0.0" @@ -3095,7 +3108,7 @@ dependencies = [ [[package]] name = "libdd-remote-config" -version = "2.0.0" +version = "3.0.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -3164,6 +3177,8 @@ dependencies = [ "libdd-shared-runtime", "serde", "serde_json", + "strum", + "strum_macros", "sys-info", "tokio", "tokio-util", @@ -3192,7 +3207,7 @@ dependencies = [ [[package]] name = "libdd-tinybytes" -version = "1.1.1" +version = "1.1.2" dependencies = [ "libdd-tinybytes", "once_cell", @@ -3206,7 +3221,7 @@ dependencies = [ [[package]] name = "libdd-trace-normalization" -version = "3.0.0" +version = "3.0.1" dependencies = [ "anyhow", "arbitrary", @@ -3235,7 +3250,7 @@ dependencies = [ [[package]] name = "libdd-trace-protobuf" -version = "4.0.0" +version = "4.0.1" dependencies = [ "bolero", "prost", @@ -3280,7 +3295,7 @@ dependencies = [ [[package]] name = "libdd-trace-utils" -version = "9.0.0" +version = "10.0.0" dependencies = [ "anyhow", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index ed73244658..6b3b87141e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,23 +41,30 @@ inherits = "release" # #2253 consolidated `anyhow`, `serde`, `tokio` and `tracing` to the workspace # level, so they are mirrored here too. [workspace.dependencies] +allocator-api2 = { version = "0.2.21", default-features = false } 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 } +bolero = { version = "0.13.4", default-features = false } +chrono = { version = "0.4.38", default-features = false } +clap = { version = "4.3.21", default-features = false } +criterion = { version = "0.5.1", default-features = true } +cxx-build = { version = "1.0", default-features = false } +elf = { version = "0.7", 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 } +tempfile = { version = "3.13", default-features = false, features = ["getrandom"] } 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 aa1ba9dad6..abf878b33a 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,libdd-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 04134a1711..895f4c42f7 100644 --- a/appsec/cmake/ddtrace.cmake +++ b/appsec/cmake/ddtrace.cmake @@ -1,6 +1,12 @@ include(ExternalProject) -set(CARGO_BUILD_CMD "cargo build") +if(DD_APPSEC_SSI) + set(CARGO_BUILD_CMD "cargo build") +else() + # Only the static library is linked into ddtrace. Building the cdylib as well fails on macOS + # because it references symbols that are provided by the final ddtrace extension. + set(CARGO_BUILD_CMD "cargo rustc --lib --crate-type staticlib") +endif() set(CARGO_BUILD_ENV "") # Initialize to empty @@ -26,9 +32,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) @@ -137,6 +143,9 @@ if (PhpConfig_VERNUM LESS 80100) list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../tracer/handlers_fiber.c") endif() list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../ext/crashtracking_windows.c") +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux") + list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../tracer/otel_context.c") +endif() find_package(CURL REQUIRED) message(STATUS "CURL version: ${CURL_VERSION_STRING}") @@ -153,6 +162,14 @@ target_compile_options(ddtrace PRIVATE -fms-extensions -Wno-microsoft-anon-tag) if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux") target_compile_definitions(ddtrace PRIVATE _GNU_SOURCE) target_link_options(ddtrace PRIVATE "-Wl,--version-script=${EXPORTS_FILE}") + if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64)$") + include(CheckCCompilerFlag) + check_c_compiler_flag("-mtls-dialect=gnu2" COMPILER_HAS_GNU2_TLS_DIALECT) + if(NOT COMPILER_HAS_GNU2_TLS_DIALECT) + message(FATAL_ERROR "x86-64 Linux OTel context sharing requires compiler support for -mtls-dialect=gnu2") + endif() + target_compile_options(ddtrace PRIVATE -mtls-dialect=gnu2) + endif() elseif(APPLE) target_link_options(ddtrace PRIVATE "-exported_symbols_list" "${EXPORTS_FILE}") else() diff --git a/appsec/src/helper/service.hpp b/appsec/src/helper/service.hpp index 9bb4662b40..01b42c758b 100644 --- a/appsec/src/helper/service.hpp +++ b/appsec/src/helper/service.hpp @@ -379,6 +379,8 @@ template <> struct fmt::formatter { return fmt::format_to(ctx.out(), "COUNT"); case DDOG_METRIC_TYPE_DISTRIBUTION: return fmt::format_to(ctx.out(), "DISTRIBUTION"); + case DDOG_METRIC_TYPE_RATE: + return fmt::format_to(ctx.out(), "RATE"); } return fmt::format_to(ctx.out(), "UNKNOWN"); } diff --git a/benchmark/otel-profiler-context/README.md b/benchmark/otel-profiler-context/README.md new file mode 100644 index 0000000000..22f5c182f2 --- /dev/null +++ b/benchmark/otel-profiler-context/README.md @@ -0,0 +1,72 @@ +# OTel profiler-context benchmark + +This Linux benchmark measures the real PHP profiler time-sampling path while it +reads OTel Process and Thread Context. It loads optimized NTS builds of both +`ddtrace.so` and `datadog-profiling.so`; the profiler is built with the existing +`trigger_time_sample` benchmark feature. + +The workload calls `Datadog\Profiling\trigger_time_sample()` from PHP. On the +PHP 8.3 benchmark runtime, returning from that internal function processes the +pending profiler VM interrupt, so every iteration walks the PHP stack, decodes +Thread Context, resolves effective profile tags, and submits a sample. + +## Build and run + +```sh +benchmark/otel-profiler-context/run.sh build +benchmark/otel-profiler-context/run.sh run +``` + +The modes are: + +- `inactive`: no active OTel Thread Context. +- `matching`: active context identity matches the process/base tags. +- `overrides`: stable thread-context service, environment, and version differ + from the process/base tags. + +Run one mode or override the defaults: + +```sh +benchmark/otel-profiler-context/run.sh run overrides 20 50000 +EPOCHS=30 ITERATIONS=100000 benchmark/otel-profiler-context/run.sh run +``` + +Each epoch emits one JSON object. After flushing its final result, the workload +kills its disposable container process so extension shutdown remains outside +the benchmark. + +Summarize a saved run with: + +```sh +jq -s 'group_by(.mode)[] | { + mode: .[0].mode, + median_ns: (map(.ns_per_sample) | sort | .[length / 2 | floor]), + min_ns: (map(.ns_per_sample) | min), + max_ns: (map(.ns_per_sample) | max) +}' results.jsonl +``` + +The important comparison for Thread Context allocation changes is the extra +cost of `overrides` relative to `matching`, not just the absolute sample cost. + +## Noise control and counters + +The measured container is pinned to CPU 2 and 4 GiB by default. Select another +Docker VM or native Linux CPU with `CPU=...`. + +```sh +CPU=4 benchmark/otel-profiler-context/run.sh run overrides +``` + +On a native Linux Docker host, collect hardware counters with: + +```sh +benchmark/otel-profiler-context/run.sh perf overrides 50000 +``` + +Docker Desktop commonly does not expose PMU counters. Interleave baseline and +candidate runs, keep Docker resource settings fixed, and avoid other CPU-heavy +work while comparing revisions. + +Run `benchmark/otel-profiler-context/run.sh clean` to remove the dedicated build +volume. diff --git a/benchmark/otel-profiler-context/run.sh b/benchmark/otel-profiler-context/run.sh new file mode 100755 index 0000000000..e8cc6702f7 --- /dev/null +++ b/benchmark/otel-profiler-context/run.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +IMAGE=${IMAGE:-ddtrace-otel-profiler-context-benchmark:php83} +VOLUME=${VOLUME:-ddtrace-otel-profiler-context-benchmark} +CPU=${CPU:-2} +MEMORY=${MEMORY:-4g} +BUILD_MEMORY=${BUILD_MEMORY:-12g} +COMMAND=${1:-run} + +container() { + docker run --rm \ + --init \ + --cpuset-cpus="$CPU" \ + --memory="$MEMORY" \ + -v "$VOLUME:/work" \ + "$@" +} + +build_image() { + docker build \ + -t "$IMAGE" \ + -f "$ROOT/benchmark/otel-context-switch/Dockerfile" \ + "$ROOT/benchmark/otel-context-switch" +} + +ensure_image() { + docker image inspect "$IMAGE" >/dev/null 2>&1 || build_image +} + +sync_source() { + docker volume create "$VOLUME" >/dev/null + docker run --rm \ + -v "$ROOT:/source:ro" \ + -v "$VOLUME:/work" \ + "$IMAGE" \ + rsync -a --delete \ + --exclude=.git \ + --exclude=.cargo-registry \ + --exclude=target \ + --exclude=tmp \ + /source/ /work/ +} + +build_extensions() { + sync_source + docker run --rm \ + --memory="$BUILD_MEMORY" \ + -v "$VOLUME:/work" \ + "$IMAGE" \ + sh -c ' + make -C /work -j"$(nproc)" all \ + "CFLAGS=-O2 -g0 -DNDEBUG -Wall -Wextra" + make -C /work compile_profiler + ' +} + +revision() { + git -C "$ROOT" rev-parse --verify HEAD | tr -d '\n' + if ! git -C "$ROOT" diff --quiet || ! git -C "$ROOT" diff --cached --quiet; then + printf '%s' '-dirty' + fi +} + +benchmark_env=( + -e DD_SERVICE=benchmark-base-service + -e DD_ENV=benchmark-base-environment + -e DD_VERSION=benchmark-base-version + -e DD_TRACE_ENABLED=1 + -e DD_TRACE_AUTO_FLUSH_ENABLED=0 + -e DD_TRACE_GENERATE_ROOT_SPAN=0 + -e DD_CODE_ORIGIN_FOR_SPANS_ENABLED=0 + -e DD_DATA_STREAMS_ENABLED=0 + -e DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 + -e DD_PROFILING_ENABLED=1 + -e DD_PROFILING_WALLTIME_ENABLED=0 + -e DD_PROFILING_EXPERIMENTAL_CPU_TIME_ENABLED=0 + -e DD_PROFILING_ALLOCATION_ENABLED=0 + -e DD_PROFILING_EXCEPTION_ENABLED=0 + -e DD_PROFILING_TIMELINE_ENABLED=0 + -e DD_PROFILING_OUTPUT_PPROF=/tmp/ddprof-benchmark + -e DD_REMOTE_CONFIG_ENABLED=0 + -e DD_TRACE_HEALTH_METRICS_ENABLED=0 + -e DD_TRACE_STARTUP_LOGS=0 + -e DD_TRACE_LOG_LEVEL=off +) + +php_command=( + php -n + -d extension=/work/tmp/build_extension/modules/ddtrace.so + -d extension=/work/tmp/build_profiler/release/libdatadog_php_profiling.so + /work/benchmark/otel-profiler-context/workload.php +) + +run_benchmark() { + local mode=${1:-overrides} + local epochs=${2:-${EPOCHS:-20}} + local iterations=${3:-${ITERATIONS:-50000}} + + local status=0 + container \ + -e "GIT_REVISION=$(revision)" \ + "${benchmark_env[@]}" \ + -e EPOCHS -e ITERATIONS -e WARMUP_ITERATIONS \ + "$IMAGE" \ + "${php_command[@]}" "$mode" "$epochs" "$iterations" || status=$? + if [[ $status -ne 0 && $status -ne 137 ]]; then + return "$status" + fi +} + +case "$COMMAND" in + image) + build_image + ;; + sync) + ensure_image + sync_source + ;; + build) + ensure_image + build_extensions + ;; + run) + shift || true + if [[ $# -gt 0 ]]; then + run_benchmark "$@" + else + run_benchmark inactive + run_benchmark matching + run_benchmark overrides + fi + ;; + perf) + shift || true + mode=${1:-overrides} + iterations=${2:-${ITERATIONS:-50000}} + status=0 + container \ + --cap-add PERFMON \ + -e "GIT_REVISION=$(revision)" \ + "${benchmark_env[@]}" \ + "$IMAGE" \ + perf stat -e cycles,instructions,branches,branch-misses,cache-misses \ + "${php_command[@]}" "$mode" 1 "$iterations" || status=$? + if [[ $status -ne 0 && $status -ne 137 ]]; then + exit "$status" + fi + ;; + clean) + docker volume rm -f "$VOLUME" + ;; + *) + echo "usage: $0 {image|sync|build|run [MODE [EPOCHS [ITERATIONS]]]|perf [MODE [ITERATIONS]]|clean}" >&2 + exit 2 + ;; +esac diff --git a/benchmark/otel-profiler-context/workload.php b/benchmark/otel-profiler-context/workload.php new file mode 100644 index 0000000000..19f34e362f --- /dev/null +++ b/benchmark/otel-profiler-context/workload.php @@ -0,0 +1,86 @@ +service = 'benchmark-base-service'; + $root->env = 'benchmark-base-environment'; + $root->version = 'benchmark-base-version'; + } else { + $root->service = 'benchmark-thread-context-service'; + $root->env = 'benchmark-thread-context-environment'; + $root->version = 'benchmark-thread-context-version'; + } +} elseif ($mode !== 'inactive') { + throw new InvalidArgumentException("unknown mode: $mode"); +} + +function positiveInt(string $value, string $name): int +{ + if (!ctype_digit($value) || (int) $value < 1) { + throw new InvalidArgumentException("$name must be a positive integer"); + } + return (int) $value; +} + +function collectSamples(int $iterations): void +{ + for ($i = 0; $i < $iterations; ++$i) { + Datadog\Profiling\trigger_time_sample(); + } +} + +collectSamples($warmupIterations); + +for ($epoch = 1; $epoch <= $epochs; ++$epoch) { + $started = hrtime(true); + collectSamples($iterations); + $elapsed = hrtime(true) - $started; + + echo json_encode([ + 'benchmark' => 'otel-profiler-context', + 'mode' => $mode, + 'epoch' => $epoch, + 'iterations' => $iterations, + 'elapsed_ns' => $elapsed, + 'ns_per_sample' => $elapsed / $iterations, + 'samples_per_second' => $iterations * 1_000_000_000 / $elapsed, + 'php_version' => PHP_VERSION, + 'ddtrace_version' => phpversion('ddtrace'), + 'profiler_version' => phpversion('datadog-profiling'), + 'git_revision' => getenv('GIT_REVISION') ?: 'unknown', + 'architecture' => php_uname('m'), + ], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), "\n"; +} + +// Keep the root alive and attached through every measured sample. +unset($root); + +// Extension shutdown is outside the measured operation. Each mode runs in a +// disposable container, so skip teardown after all results have been flushed. +fflush(STDOUT); +exec('/bin/kill -9 ' . getmypid()); diff --git a/components-rs/Cargo.toml b/components-rs/Cargo.toml index 7c78ff2cad..58b86d7f46 100644 --- a/components-rs/Cargo.toml +++ b/components-rs/Cargo.toml @@ -15,7 +15,7 @@ libdd-telemetry-ffi = { path = "../libdatadog/libdd-telemetry-ffi", default-feat datadog-live-debugger = { path = "../libdatadog/datadog-live-debugger" } datadog-live-debugger-ffi = { path = "../libdatadog/datadog-live-debugger-ffi", default-features = false } datadog-ipc = { path = "../libdatadog/datadog-ipc" } -datadog-ffe = { path = "../libdatadog/datadog-ffe" } +datadog-ffe = { package = "libdd-ffe", path = "../libdatadog/libdd-ffe" } libdd-remote-config = { path = "../libdatadog/libdd-remote-config" } datadog-sidecar = { path = "../libdatadog/datadog-sidecar" } datadog-sidecar-ffi = { path = "../libdatadog/datadog-sidecar-ffi" } @@ -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 fc5779e6ba..bda6910746 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/common.h b/components-rs/common.h index 61e73538fc..516014cb99 100644 --- a/components-rs/common.h +++ b/components-rs/common.h @@ -283,6 +283,9 @@ typedef enum ddog_ConfigurationOrigin { DDOG_CONFIGURATION_ORIGIN_LOCAL_STABLE_CONFIG, DDOG_CONFIGURATION_ORIGIN_FLEET_STABLE_CONFIG, DDOG_CONFIGURATION_ORIGIN_CALCULATED, + DDOG_CONFIGURATION_ORIGIN_OTEL_ENV_VAR, + DDOG_CONFIGURATION_ORIGIN_INI, + DDOG_CONFIGURATION_ORIGIN_UNKNOWN, } ddog_ConfigurationOrigin; typedef enum ddog_DynamicConfigUpdateMode { @@ -349,12 +352,17 @@ typedef enum ddog_MetricNamespace { DDOG_METRIC_NAMESPACE_TELEMETRY, DDOG_METRIC_NAMESPACE_APM, DDOG_METRIC_NAMESPACE_SIDECAR, + DDOG_METRIC_NAMESPACE_CIVISIBILITY, + DDOG_METRIC_NAMESPACE_MLOBS, + DDOG_METRIC_NAMESPACE_DDTRACEAPI, + DDOG_METRIC_NAMESPACE_AI_GUARD, } ddog_MetricNamespace; typedef enum ddog_MetricType { DDOG_METRIC_TYPE_GAUGE, DDOG_METRIC_TYPE_COUNT, DDOG_METRIC_TYPE_DISTRIBUTION, + DDOG_METRIC_TYPE_RATE, } ddog_MetricType; typedef enum ddog_ProbeStatus { @@ -427,8 +435,8 @@ typedef enum ddog_RemoteConfigProduct { DDOG_REMOTE_CONFIG_PRODUCT_ASM_DD, DDOG_REMOTE_CONFIG_PRODUCT_ASM_FEATURES, DDOG_REMOTE_CONFIG_PRODUCT_FFE_FLAGS, - DDOG_REMOTE_CONFIG_PRODUCT_LIVE_DEBUGGER, - DDOG_REMOTE_CONFIG_PRODUCT_LIVE_DEBUGGER_SYMBOL_DB, + DDOG_REMOTE_CONFIG_PRODUCT_LIVE_DEBUGGING, + DDOG_REMOTE_CONFIG_PRODUCT_LIVE_DEBUGGING_SYMBOL_DB, } ddog_RemoteConfigProduct; typedef enum ddog_SpanProbeTarget { @@ -1206,6 +1214,7 @@ typedef struct ddog_NativeFile { typedef struct ddog_SidecarFlushOptions { bool traces_and_stats; + bool flag_evaluations; bool telemetry; } ddog_SidecarFlushOptions; @@ -1253,6 +1262,40 @@ typedef struct ddog_Slice_FfeExposure { uintptr_t len; } ddog_Slice_FfeExposure; +typedef struct ddog_FfeFlagEvaluation { + int64_t timestamp_ms; + ddog_CharSlice flag_key; + int64_t first_evaluation_ms; + int64_t last_evaluation_ms; + uint64_t evaluation_count; + ddog_CharSlice variant; + ddog_CharSlice allocation_key; + ddog_CharSlice targeting_rule_key; + ddog_CharSlice targeting_key; + /** + * UTF-8 JSON object. Empty, invalid, or non-object JSON is omitted. Object + * values are pruned to 256 leaf fields, 256-byte string values, and four + * levels of nested context depth. + */ + ddog_CharSlice evaluation_context_json; + ddog_CharSlice error_message; + bool runtime_default_used; +} ddog_FfeFlagEvaluation; + +typedef struct ddog_Slice_FfeFlagEvaluation { + /** + * Should be non-null and suitably aligned for the underlying type. It is + * allowed but not recommended for the pointer to be null when the len is + * zero. + */ + const struct ddog_FfeFlagEvaluation *ptr; + /** + * The number of elements (not bytes) that `.ptr` points to. Must be less + * than or equal to [isize::MAX]. + */ + uintptr_t len; +} ddog_Slice_FfeFlagEvaluation; + typedef struct ddog_FfeEvaluationMetric { ddog_CharSlice flag_key; ddog_CharSlice variant; diff --git a/components-rs/datadog.h b/components-rs/datadog.h index 21c7f656fe..b86141e87f 100644 --- a/components-rs/datadog.h +++ b/components-rs/datadog.h @@ -41,6 +41,14 @@ void datadog_generate_session_id(void); void datadog_format_runtime_id(uint8_t (*buf)[36]); +#ifdef __linux__ +/** + * Publish or update dd-trace-php's standard Linux OTel Process Context. + */ +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 ae44c38028..744fc5031a 100644 --- a/components-rs/lib.rs +++ b/components-rs/lib.rs @@ -90,6 +90,58 @@ 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() + }; + + otel_process_ctx::publish(&metadata.to_otel_process_ctx()).is_ok() +} + #[must_use] #[no_mangle] pub extern "C" fn ddtrace_get_container_id() -> CharSlice<'static> { diff --git a/components-rs/remote_config.rs b/components-rs/remote_config.rs index 6e085848f6..7bbddf0d97 100644 --- a/components-rs/remote_config.rs +++ b/components-rs/remote_config.rs @@ -164,12 +164,12 @@ pub unsafe extern "C" fn ddog_init_remote_config(flags: RemoteConfigFlags) { } if live_debugging_enabled { - DATADOG_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::LiveDebugger) + DATADOG_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::LiveDebugging) } if appsec_config { DATADOG_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::AsmData); - DATADOG_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::AsmDD); + DATADOG_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::AsmDd); DATADOG_REMOTE_CONFIG_PRODUCTS.push(RemoteConfigProduct::Asm); [ RemoteConfigCapabilities::AsmIpBlocking, @@ -390,7 +390,7 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt } => { if let Some(data) = value.data { match value.product { - RemoteConfigProduct::LiveDebugger => { + RemoteConfigProduct::LiveDebugging => { let val = Box::new((data, MaybeShmLimiter::open(limiter_index))); let rc_ref: &mut RemoteConfigState = unsafe { mem::transmute(remote_config as *mut _) }; // sigh, borrow checker let config_id = value.config_id.clone(); @@ -439,7 +439,7 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt } } RemoteConfigUpdate::Remove(path) => match path.product { - RemoteConfigProduct::LiveDebugger => { + RemoteConfigProduct::LiveDebugging => { if let Some(boxed) = remote_config.live_debugger.active.remove(&path.config_id) { if let Some(debugger) = boxed.0.downcast::() { remove_config(remote_config, &path.config_id, debugger); @@ -755,7 +755,7 @@ pub extern "C" fn ddog_rshutdown_remote_config(remote_config: &mut RemoteConfigS remote_config.dynamic_config.merged_configs.clear(); remote_config.manager.unload_configs(&[ RemoteConfigProduct::ApmTracing, - RemoteConfigProduct::LiveDebugger, + RemoteConfigProduct::LiveDebugging, ]); } diff --git a/components-rs/sidecar.h b/components-rs/sidecar.h index de6df3cb55..72ea5e9e02 100644 --- a/components-rs/sidecar.h +++ b/components-rs/sidecar.h @@ -333,6 +333,21 @@ ddog_MaybeError ddog_sidecar_send_ffe_exposure_batch(struct ddog_SidecarTranspor const struct ddog_FfeTelemetryContext *context, struct ddog_Slice_FfeExposure exposures); +/** + * Send structured FFE flag evaluation events to the sidecar. The sidecar owns + * JSON serialization and Agent EVP delivery. This function is caller-driven; + * callers must aggregate and bound event cardinality before passing a batch. + * + * # Safety + * `context` and every element in `flag_evaluations` must contain valid UTF-8 + * `CharSlice` values. Empty `flag_evaluations` is a no-op. + */ +ddog_MaybeError ddog_sidecar_send_ffe_flag_evaluation_batch(struct ddog_SidecarTransport **transport, + const struct ddog_InstanceId *instance_id, + const ddog_QueueId *queue_id, + const struct ddog_FfeTelemetryContext *context, + struct ddog_Slice_FfeFlagEvaluation flag_evaluations); + /** * Send structured FFE evaluation metric events to the sidecar. The sidecar * owns aggregation, OTLP/protobuf serialization, and OTLP HTTP delivery. This diff --git a/components-rs/telemetry.rs b/components-rs/telemetry.rs index 2c55aa53f7..17425ae9a3 100644 --- a/components-rs/telemetry.rs +++ b/components-rs/telemetry.rs @@ -105,6 +105,7 @@ pub unsafe extern "C" fn ddog_sidecar_telemetry_addIntegration_buffer( version, compatible: None, auto_enabled: None, + error: None, }); buffer.buffer.push(SidecarAction::Telemetry(action)); } @@ -121,6 +122,8 @@ pub unsafe extern "C" fn ddog_sidecar_telemetry_addDependency_buffer( let action = TelemetryActions::AddDependency(Dependency { name: dependency_name.to_utf8_lossy().into_owned(), version, + hash: None, + metadata: None, }); buffer.buffer.push(SidecarAction::Telemetry(action)); } @@ -139,6 +142,9 @@ pub unsafe extern "C" fn ddog_sidecar_telemetry_addEndpoint_buffer( path: Some(path.to_utf8_lossy().into_owned()), operation_name: operation_name.to_utf8_lossy().into_owned(), resource_name: resource_name.to_utf8_lossy().into_owned(), + response_code: None, + request_body_type: None, + response_body_type: None, }); buffer.buffer.push(SidecarAction::Telemetry(action)); } @@ -158,7 +164,7 @@ pub unsafe extern "C" fn ddog_sidecar_telemetry_enqueueConfig_buffer( }; let action = TelemetryActions::AddConfig(data::Configuration { name: config_key.to_utf8_lossy().into_owned(), - value: config_value.to_utf8_lossy().into_owned(), + value: Some(config_value.to_utf8_lossy().into_owned()), origin, config_id, seq_id: None, diff --git a/config.m4 b/config.m4 index aa69d309a4..f89e3461e3 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 0000000000..227c07946a --- /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 fbd031d1ca..6ef4880a45 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_force_new_instance_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 224774d3a5..38bb2a73ed 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -469,7 +469,8 @@ void datadog_sidecar_handle_fork(void) { return; } - datadog_force_new_instance_id(); + // The child instance ID is regenerated by datadog_internal_handle_fork() + // before deciding whether the sidecar itself is enabled. // After fork only one thread (the one that called fork) survives, so we only // need to drop and reconnect the current thread's transport. @@ -595,9 +596,10 @@ void datadog_sidecar_shutdown(void) { void datadog_force_new_instance_id(void) { if (datadog_sidecar_instance_id) { ddog_sidecar_instanceId_drop(datadog_sidecar_instance_id); - datadog_generate_runtime_id(); - dd_set_resettable_sidecar_globals(); + datadog_sidecar_instance_id = NULL; } + datadog_generate_runtime_id(); + dd_set_resettable_sidecar_globals(); } ddog_Endpoint *datadog_sidecar_agent_endpoint(void) { diff --git a/libdatadog b/libdatadog index 95610de06a..3da894a0ec 160000 --- a/libdatadog +++ b/libdatadog @@ -1 +1 @@ -Subproject commit 95610de06a776b8d645fe77ad8b8e1848ecd53b7 +Subproject commit 3da894a0ec74abea0dfe9a3417919825c56b4e65 diff --git a/loader/dd_library_loader.c b/loader/dd_library_loader.c index 4eaff1efa7..6d7b40ce08 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 b69cba5d1d..013638deb1 100644 --- a/package.xml +++ b/package.xml @@ -75,6 +75,7 @@ ${changelog} ${codefiles} + diff --git a/profiling/Cargo.toml b/profiling/Cargo.toml index 37f9dea8dd..c2ae57147d 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/config.rs b/profiling/src/config.rs index 6485554d27..07f8c1785d 100644 --- a/profiling/src/config.rs +++ b/profiling/src/config.rs @@ -14,7 +14,6 @@ use core::ptr; use core::str::FromStr; pub use http::Uri; use libc::{c_char, c_int}; -use libdd_common::tag::{parse_tags, Tag}; use log::{debug, error, warn, LevelFilter}; use std::borrow::Cow; use std::ffi::CString; @@ -776,11 +775,8 @@ pub(crate) unsafe fn git_repository_url() -> Option { /// # Safety /// This function must only be called after config has been initialized in /// rinit, and before it is uninitialized in mshutdown. -pub(crate) unsafe fn tags() -> (Vec, Option) { - match get_str(Tags) { - None => (Vec::new(), None), - Some(dd_tags) => parse_tags(&dd_tags), - } +pub(crate) unsafe fn tags() -> Option { + get_str(Tags) } /// # Safety diff --git a/profiling/src/lib.rs b/profiling/src/lib.rs index f815f1514c..ece08dfb59 100644 --- a/profiling/src/lib.rs +++ b/profiling/src/lib.rs @@ -9,6 +9,9 @@ mod pthread; mod sapi; mod wall_time; +mod process_context; +mod profile_tags; + #[cfg(php_run_time_cache)] mod string_set; @@ -35,8 +38,9 @@ use bindings::{ use clocks::*; use core::ffi::{c_char, c_int, CStr}; use core::ptr; -use libdd_common::{cstr, tag, tag::Tag}; +use libdd_common::cstr; use log::{debug, error, info, trace, warn}; +use profile_tags::{ProfileTagSegment, UnifiedServiceTagSegment}; use profiling::{LocalRootSpanResourceMessage, Profiler, VmInterrupt}; use sapi::Sapi; use std::borrow::Cow; @@ -93,32 +97,39 @@ static mut RUNTIME_PHP_VERSION: &str = { /// # Safety /// 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(|| { - 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"), - ]; - - // This should probably be "language_version", but this is the - // standardized tag name. - // SAFETY: PHP_VERSION is safe to access in rinit (only - // mutated during minit). - add_tag(&mut tags, "runtime_version", unsafe { RUNTIME_PHP_VERSION }); - add_tag(&mut tags, "php.sapi", SAPI.as_ref()); +/// Process-common profile tags serialized once and shared by every sample. +pub(crate) 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(); + // SAFETY: calling getpid() is safe. + let process_id = unsafe { libc::getpid() }.to_string(); // In case we ever add PHP debug build support, we should add `zend-zts-debug` and // `zend-nts-debug`. For the time being we only support `zend-zts-ndebug` and - // `zend-nts-ndebug` + // `zend-nts-ndebug`. let runtime_engine = if cfg!(php_zts) { "zend-zts-ndebug" } else { "zend-nts-ndebug" }; - add_tag(&mut tags, "runtime_engine", runtime_engine); - tags + let values = [ + ("language", "php"), + ("profiler_version", env!("PROFILER_VERSION")), + ("process_id", process_id.as_str()), + ("runtime-id", runtime_id.as_str()), + // This should probably be "language_version", but this is the + // standardized tag name. + // SAFETY: PHP_VERSION is only mutated during MINIT and this is first + // accessed during RINIT. + ("runtime_version", unsafe { RUNTIME_PHP_VERSION }), + ("php.sapi", SAPI.as_ref()), + ("runtime_engine", runtime_engine), + ]; + Arc::new( + ProfileTagSegment::try_from_kv_slice(&values) + .expect("process-common profile tags to be valid and allocatable"), + ) }); /// The Server API the profiler is running under. @@ -159,10 +170,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")), @@ -409,12 +423,10 @@ extern "C" fn prshutdown() -> ZendResult { } pub struct RequestLocals { - pub env: Option, - pub service: Option, - pub version: Option, - pub git_commit_sha: Option, - pub git_repository_url: Option, - pub tags: Vec, + pub(crate) identity: process_context::ProcessIdentity, + pub(crate) unified_service_tags: Arc, + pub(crate) git_tags: Option>, + pub(crate) custom_tags: Option>, /// SystemSettings are global. Note that if this is being read in fringe /// conditions such as in mshutdown when there were no requests served, @@ -441,12 +453,10 @@ impl RequestLocals { impl Default for RequestLocals { fn default() -> RequestLocals { RequestLocals { - env: None, - service: None, - version: None, - git_commit_sha: None, - git_repository_url: None, - tags: vec![], + identity: process_context::ProcessIdentity::default(), + unified_service_tags: Arc::default(), + git_tags: None, + custom_tags: None, system_settings: SystemSettings::get(), profiling_experimental_heap_live_enabled: false, vm_interrupt_addr: ptr::null_mut(), @@ -521,13 +531,6 @@ thread_local! { }); static REQUEST_LOCALS: RefCell = RefCell::new(RequestLocals::default()); - - /// The tags for this thread/request. These get sent to other threads, - /// which is why they are Arc. However, they are wrapped in a RefCell - /// because the values _can_ change from request to request depending on - /// the values sent in the SAPI for env, service, version, etc. They get - /// reset at the end of the request. - static TAGS: RefCell>> = RefCell::new(Arc::new(Vec::new())); } /// Gets the runtime-id for the process. Do not call before RINIT! @@ -585,9 +588,9 @@ extern "C" fn rinit(_type: c_int, _module_number: c_int) -> ZendResult { // SAFETY: We are after first rinit and before mshutdown. unsafe { - locals.env = config::env(); - locals.service = config::service().or_else(|| { - match *SAPI { + locals.identity = process_context::ProcessIdentity { + env: config::env(), + service: config::service().or_else(|| match *SAPI { Sapi::Cli => { // SAFETY: sapi globals are safe to access during rinit SAPI.request_script_name(datadog_sapi_globals_request_info()) @@ -595,44 +598,72 @@ extern "C" fn rinit(_type: c_int, _module_number: c_int) -> ZendResult { .or(Some(String::from("cli.command"))) } _ => Some(String::from("web.request")), - } - }); - locals.version = config::version(); - locals.git_commit_sha = config::git_commit_sha(); - locals.git_repository_url = config::git_repository_url().map(|val| { + }), + version: config::version(), + }; + + let git_commit_sha = config::git_commit_sha(); + let git_repository_url = config::git_repository_url().map(|value| { // Remove potential credentials, customers are encouraged to not send those anyway. - if let Some(at_pos) = val.find("@") { - if let Some(proto_pos) = val.find("://") { - // Keep protocol, but remove credentials - format!("{}{}", &val[..(proto_pos + 3)], &val[(at_pos + 1)..]) + if let Some(at_pos) = value.find('@') { + if let Some(proto_pos) = value.find("://") { + format!("{}{}", &value[..(proto_pos + 3)], &value[(at_pos + 1)..]) } else { - // No protocol, just remove everything before @ - val[(at_pos + 1)..].to_string() + value[(at_pos + 1)..].to_owned() } } else { - val + value } }); - - let (tags, maybe_err) = config::tags(); - if let Some(err) = maybe_err { - // DD_TAGS can change on each request, so this warns on every - // request. Maybe we should cache the error string and only - // emit warnings for new ones? - warn!("{err}"); + let mut git = [("", ""); 2]; + let mut git_len = 0; + if let Some(value) = git_commit_sha.as_deref() { + git[git_len] = ("git.commit.sha", value); + git_len += 1; } - locals.tags = tags; + if let Some(value) = git_repository_url.as_deref() { + git[git_len] = ("git.repository_url", value); + git_len += 1; + } + locals.git_tags = (git_len != 0) + .then(|| ProfileTagSegment::try_from_kv_slice(&git[..git_len]).map(Arc::new)) + .transpose()?; + + locals.unified_service_tags = Arc::new(UnifiedServiceTagSegment::try_new( + locals.identity.service.as_deref().unwrap_or_default(), + locals.identity.env.as_deref().unwrap_or_default(), + locals.identity.version.as_deref().unwrap_or_default(), + )?); + + let mut custom = ProfileTagSegment::default(); + if let Some(tags) = config::tags() { + if let Some(error) = custom.try_push_tags(&tags)? { + // DD_TAGS can change on each request, so this warns on every + // request. Maybe we should cache the error string and only + // emit warnings for new ones? + warn!("{error}"); + } + } + locals.custom_tags = (custom.len() != 0).then(|| Arc::new(custom)); locals.profiling_experimental_heap_live_enabled = system_settings .as_ref() .profiling_experimental_heap_live_enabled && config::profiling_experimental_heap_live_enabled_current(); } locals.system_settings = system_settings; + Ok::<(), profile_tags::ProfileTagError>(()) }); - if let Err(err) = result { - error!("failed to borrow request locals in rinit: {err}"); - return ZendResult::Failure; + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => { + error!("failed to construct request profile tags in rinit: {error}"); + return ZendResult::Failure; + } + Err(error) => { + error!("failed to borrow request locals in rinit: {error}"); + return ZendResult::Failure; + } } // Preloading happens before zend_post_startup_cb is called for the first @@ -709,27 +740,6 @@ extern "C" fn rinit(_type: c_int, _module_number: c_int) -> ZendResult { let wall_time_enabled = system_settings.profiling_wall_time_enabled; CLOCKS.with_borrow_mut(|clocks| clocks.initialize(cpu_time_enabled)); - 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 - + 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, "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()); - Arc::new(tags) - }); - // Only add interrupt if cpu- or wall-time is enabled. if !(cpu_time_enabled | wall_time_enabled) { return; @@ -746,8 +756,6 @@ extern "C" fn rinit(_type: c_int, _module_number: c_int) -> ZendResult { profiler.add_interrupt(interrupt); } }); - } else { - TAGS.set(Arc::default()); } allocation::rinit(); @@ -758,20 +766,6 @@ extern "C" fn rinit(_type: c_int, _module_number: c_int) -> ZendResult { ZendResult::Success } -fn add_optional_tag>(tags: &mut Vec, key: &str, value: &Option) { - if let Some(value) = value { - add_tag(tags, key, value.as_ref()); - } -} - -fn add_tag(tags: &mut Vec, key: &str, value: &str) { - assert!(!value.is_empty()); - match Tag::new(key, value) { - Ok(tag) => tags.push(tag), - Err(err) => warn!("invalid {key} tag: {err}"), - } -} - extern "C" fn rshutdown(_type: c_int, _module_number: c_int) -> ZendResult { #[cfg(feature = "tracing")] let _rshutdown_span = tracing::info_span!("rshutdown").entered(); @@ -984,9 +978,18 @@ unsafe extern "C" fn minfo(module_ptr: *mut zend::ModuleEntry) { zend::php_info_print_table_row(2, key, agent_endpoint.as_ptr()); let vars = [ - (c"Application's Environment (DD_ENV)".as_ptr(), &locals.env), - (c"Application's Service (DD_SERVICE)".as_ptr(), &locals.service), - (c"Application's Version (DD_VERSION)".as_ptr(), &locals.version), + ( + c"Application's Environment (DD_ENV)".as_ptr(), + &locals.identity.env, + ), + ( + c"Application's Service (DD_SERVICE)".as_ptr(), + &locals.identity.service, + ), + ( + c"Application's Version (DD_VERSION)".as_ptr(), + &locals.identity.version, + ), ]; for (key, value) in vars { @@ -1064,7 +1067,9 @@ extern "C" fn shutdown(extension: *mut ZendExtension) { // SAFETY: calling in Zend Extension shutdown as required. if let Err(err) = unsafe { Profiler::shutdown(Duration::from_secs(2)) } { let num_failures = err.num_failures; - error!("{num_failures} thread(s) failed to join, intentionally leaking the extension's handle to prevent unloading"); + error!( + "{num_failures} thread(s) failed to join, intentionally leaking the extension's handle to prevent unloading" + ); // SAFETY: during mshutdown, we have ownership of the extension struct. // Our threads (which failed to join) do not mutate this struct at all // either, providing no races. @@ -1114,6 +1119,8 @@ fn notify_trace_finished(local_root_span_id: u64, span_type: Cow, resource: }); if let Err(err) = result { - debug!("tracer failed to notify profiler about a finished trace because the request locals could not be borrowed: {err}"); + debug!( + "tracer failed to notify profiler about a finished trace because the request locals could not be borrowed: {err}" + ); } } diff --git a/profiling/src/module_globals.rs b/profiling/src/module_globals.rs index 8c7d061a07..f35210bfa6 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 9a82c509c6..74acea9eaf 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 51a36b0859..ec7dcba417 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 0000000000..c41afe95e0 --- /dev/null +++ b/profiling/src/process_context.rs @@ -0,0 +1,44 @@ +// 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) unified_service_tags: std::sync::Arc, +} + +#[derive(Debug, Default)] +pub(crate) struct ProcessIdentity { + pub(crate) service: Option, + pub(crate) env: 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) env: Option<&'a str>, + pub(crate) version: Option<&'a str>, +} + +#[cfg(target_os = "linux")] +pub(crate) enum ThreadContextRead { + /// No valid OTel Thread Context is currently attached. + Inactive(std::sync::Arc), + 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::{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 0000000000..1c959f0c05 --- /dev/null +++ b/profiling/src/process_context/linux.rs @@ -0,0 +1,838 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use super::{ProcessIdentityRef, ThreadContext, ThreadContextRead}; +use crate::bindings::datadog_php_profiling_get_otel_thread_context; +use crate::profile_tags::UnifiedServiceTagSegment; +use libdd_library_config::otel_process_ctx::ProcessContextSelfReader; +use libdd_trace_protobuf::opentelemetry::proto::common::v1::{any_value, KeyValue, ProcessContext}; +use std::cell::RefCell; +use std::sync::Arc; + +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, +} + +struct CachedThreadContext { + attributes: Vec, + local_root_span_id: u64, + thread_id: Option, + unified_service_tags: Arc, +} + +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), + env: 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, + /// The most recently observed final UST identity. Thread Context records + /// normally remain stable for many samples, so share its serialization + /// instead of allocating and copying it for every sample. + unified_service_tags: RefCell>>, + /// Exact encoded attributes and their decoded result. Matching one + /// contiguous byte slice lets stable contexts bypass TLV decoding. + thread_context: RefCell>, + /// 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, + unified_service_tags: RefCell::new(None), + thread_context: RefCell::new(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) { + // Request-configured fallback identity may change between requests. + self.thread_context.get_mut().take(); + 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) => { + // The Thread Context key map is append-only, so cached bytes + // containing only previously known indexes retain their + // meaning. Final UST values are compared exactly before reuse. + 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 effective_identity<'a>( + &'a self, + defaults: ProcessIdentityRef<'a>, + ) -> ProcessIdentityRef<'a> { + let process = self + .context + .as_ref() + .map(CachedProcessContext::identity) + .unwrap_or_default(); + ProcessIdentityRef { + service: process.service.or(defaults.service), + env: process.env.or(defaults.env), + version: process.version.or(defaults.version), + } + } + + fn unified_service_tags( + &self, + service: &str, + env: &str, + version: &str, + ) -> Result, crate::profile_tags::ProfileTagError> { + if let Ok(cached) = self.unified_service_tags.try_borrow() { + if let Some(tags) = cached.as_ref() { + if tags.matches(service, env, version) { + return Ok(Arc::clone(tags)); + } + } + } + + let tags = Arc::new(UnifiedServiceTagSegment::try_new(service, env, version)?); + if let Ok(mut cached) = self.unified_service_tags.try_borrow_mut() { + *cached = Some(Arc::clone(&tags)); + } + Ok(tags) + } + + fn decode_thread_attributes( + &self, + attributes: &[u8], + defaults: ProcessIdentityRef<'_>, + ) -> (ThreadContext, bool) { + if let Ok(cached) = self.thread_context.try_borrow() { + if let Some(cached) = cached.as_ref() { + if cached.attributes == attributes { + return ( + ThreadContext { + local_root_span_id: cached.local_root_span_id, + span_id: 0, + thread_id: cached.thread_id, + unified_service_tags: Arc::clone(&cached.unified_service_tags), + }, + false, + ); + } + } + } + + self.decode_thread_attributes_uncached(attributes, defaults) + } + + #[cold] + #[inline(never)] + fn decode_thread_attributes_uncached( + &self, + attributes: &[u8], + defaults: ProcessIdentityRef<'_>, + ) -> (ThreadContext, bool) { + let defaults = self.effective_identity(defaults); + let offsets = self.context.as_ref().map(|cached| &cached.offsets.thread); + let mut local_root_span_id = 0; + let mut thread_id = None; + let mut service = defaults.service.unwrap_or_default(); + let mut env = defaults.env.unwrap_or_default(); + let mut version = defaults.version.unwrap_or_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) { + local_root_span_id = u64::from_str_radix(value, 16).unwrap_or_default(); + } else if offsets.service_name == Some(key_index) { + if libdd_common::tag::Tag::validate("service", value).is_ok() { + service = value; + } + } else if offsets.deployment_environment_name == Some(key_index) { + if libdd_common::tag::Tag::validate("env", value).is_ok() { + env = value; + } + } else if offsets.service_version == Some(key_index) { + if libdd_common::tag::Tag::validate("version", value).is_ok() { + version = value; + } + } else if offsets.thread_id == Some(key_index) { + thread_id = value.parse().ok().filter(|id| *id >= 0); + } + } + + offset = value_end; + } + + // TODO: propagate allocation failure to the sample caller instead of + // dropping the final identity. + let unified_service_tags = self + .unified_service_tags(service, env, version) + .unwrap_or_else(|_| Arc::default()); + + if !unknown_index { + if let Ok(mut slot) = self.thread_context.try_borrow_mut() { + if let Some(cached) = slot.as_mut() { + let additional = attributes.len().saturating_sub(cached.attributes.len()); + if cached.attributes.try_reserve(additional).is_ok() { + cached.attributes.clear(); + cached.attributes.extend_from_slice(attributes); + cached.local_root_span_id = local_root_span_id; + cached.thread_id = thread_id; + cached.unified_service_tags = Arc::clone(&unified_service_tags); + } + } else { + let mut cached_attributes = Vec::new(); + if cached_attributes + .try_reserve_exact(attributes.len()) + .is_ok() + { + cached_attributes.extend_from_slice(attributes); + *slot = Some(CachedThreadContext { + attributes: cached_attributes, + local_root_span_id, + thread_id, + unified_service_tags: Arc::clone(&unified_service_tags), + }); + } + } + } + } + + ( + ThreadContext { + local_root_span_id, + span_id: 0, + thread_id, + unified_service_tags, + }, + 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 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 inactive = || { + let unified_service_tags = with_cache(|cell| { + let Ok(cache) = cell.try_borrow() else { + return UnifiedServiceTagSegment::try_new( + defaults.service.unwrap_or_default(), + defaults.env.unwrap_or_default(), + defaults.version.unwrap_or_default(), + ) + .map(Arc::new); + }; + let identity = cache.effective_identity(defaults); + cache.unified_service_tags( + identity.service.unwrap_or_default(), + identity.env.unwrap_or_default(), + identity.version.unwrap_or_default(), + ) + }); + // TODO: propagate allocation failure to the sample caller. + ThreadContextRead::Inactive(unified_service_tags.unwrap_or_else(|_| Arc::default())) + }; + + let record = unsafe { datadog_php_profiling_get_otel_thread_context() }.cast::(); + if record.is_null() { + return 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 inactive(); + } + + let attributes_size = u16::from_ne_bytes([header[26], header[27]]) as usize; + if attributes_size > MAX_THREAD_ATTRIBUTES_SIZE { + return 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)), + unified_service_tags: RefCell::new(None), + thread_context: RefCell::new(None), + 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.env, 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"), + env: 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); + let (decoded_again, _) = cache.decode_thread_attributes( + &attributes, + ProcessIdentityRef { + service: Some("configured-service"), + env: Some("configured-env"), + version: Some("configured-version"), + }, + ); + assert!(Arc::ptr_eq( + &decoded.unified_service_tags, + &decoded_again.unified_service_tags + )); + + let changed_attributes = encoded_attributes(&[ + (0, b"not interesting"), + (1, b"42"), + (2, b"2.0.0"), + (3, b"fedcba9876543210"), + (4, b"changed-service"), + (5, b"configured-env"), + ]); + let (changed, _) = cache.decode_thread_attributes( + &changed_attributes, + ProcessIdentityRef { + service: Some("configured-service"), + env: Some("configured-env"), + version: Some("configured-version"), + }, + ); + assert!(!Arc::ptr_eq( + &decoded.unified_service_tags, + &changed.unified_service_tags + )); + assert_eq!( + changed.unified_service_tags.iter().collect::>(), + [ + "service:changed-service", + "env:configured-env", + "version:2.0.0" + ] + ); + assert_eq!( + decoded.unified_service_tags.iter().collect::>(), + [ + "service:root-service", + "env:configured-env", + "version:2.0.0" + ] + ); + } + + #[test] + fn request_initialization_invalidates_cached_fallback_identity() { + let mut cache = cache(context(vec![], vec![key_map(&["thread.id"])])); + let attributes = encoded_attributes(&[(0, b"42")]); + + let (first, _) = cache.decode_thread_attributes( + &attributes, + ProcessIdentityRef { + service: Some("first-request"), + env: None, + version: None, + }, + ); + cache.initialize(); + let (second, _) = cache.decode_thread_attributes( + &attributes, + ProcessIdentityRef { + service: Some("second-request"), + env: None, + version: None, + }, + ); + + assert_eq!( + first.unified_service_tags.iter().collect::>(), + ["service:first-request"] + ); + assert_eq!( + second.unified_service_tags.iter().collect::>(), + ["service:second-request"] + ); + } + + #[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"), + env: Some("configured-env"), + version: Some("configured-version"), + }, + ); + + assert!(unknown_index); + assert_eq!( + decoded.unified_service_tags.iter().collect::>(), + [ + "service:configured-service", + "env:configured-env", + "version:configured-version" + ] + ); + 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.unified_service_tags.len(), 0); + } +} diff --git a/profiling/src/profile_tags.rs b/profiling/src/profile_tags.rs new file mode 100644 index 0000000000..8ad6102323 --- /dev/null +++ b/profiling/src/profile_tags.rs @@ -0,0 +1,355 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + +use libdd_common::tag::{parse_tags, Tag, TagParser, TagValidationError}; +use std::collections::TryReserveError; +use std::fmt::Write; +use std::hash::{Hash, Hasher}; +use std::ops::Range; +use std::sync::Arc; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum ProfileTagError { + #[error("failed to allocate profile tag storage")] + StorageAllocation(#[from] TryReserveError), + + #[error("profile tag capacity overflow")] + CapacityOverflow, + #[error(transparent)] + Invalid(#[from] TagValidationError), +} + +/// An immutable ordered sequence of serialized tags backed by one string. +#[derive(Debug, Default)] +pub(crate) struct ProfileTagSegment { + storage: String, + tags: Vec>, +} + +impl PartialEq for ProfileTagSegment { + fn eq(&self, other: &Self) -> bool { + // Storage is the exact comma-separated intake representation; ranges + // are iteration metadata and do not contribute to profile identity. + self.storage == other.storage + } +} + +impl Eq for ProfileTagSegment {} + +impl Hash for ProfileTagSegment { + fn hash(&self, state: &mut H) { + self.storage.hash(state); + } +} + +impl ProfileTagSegment { + pub(crate) fn try_from_kv_slice(tags: &[(K, V)]) -> Result + where + K: AsRef, + V: AsRef, + { + let storage_len = tags.iter().try_fold(0usize, |len, (key, value)| { + let key = key.as_ref(); + let value = value.as_ref(); + Tag::validate(key, value)?; + len.checked_add(key.len()) + .and_then(|len| len.checked_add(value.len())) + .and_then(|len| len.checked_add(":,".len())) + .ok_or(ProfileTagError::CapacityOverflow) + })?; + + let mut segment = Self::default(); + segment.try_reserve(tags.len(), storage_len)?; + for (key, value) in tags { + segment.push_validated(key.as_ref(), value.as_ref()); + } + Ok(segment) + } + + pub(crate) fn try_reserve( + &mut self, + additional_tags: usize, + additional_bytes: usize, + ) -> Result<(), ProfileTagError> { + self.tags.try_reserve(additional_tags)?; + self.storage.try_reserve(additional_bytes)?; + Ok(()) + } + + pub(crate) fn try_push_tags(&mut self, input: &str) -> Result, ProfileTagError> { + let (tag_count, storage_len) = TagParser::new(input).try_fold( + (0usize, 0usize), + |(count, len), result| match result { + Ok(tag) => Ok::<_, ProfileTagError>(( + count + .checked_add(1) + .ok_or(ProfileTagError::CapacityOverflow)?, + len.checked_add(tag.len()) + .and_then(|len| len.checked_add(1)) + .ok_or(ProfileTagError::CapacityOverflow)?, + )), + Err(_) => Ok((count, len)), + }, + )?; + self.try_reserve(tag_count, storage_len)?; + + let mut errors = String::new(); + for result in TagParser::new(input) { + match result { + Ok(tag) => self.push_serialized_validated(tag), + Err(error) => { + let additional = error + .value + .len() + .checked_add(64) + .ok_or(ProfileTagError::CapacityOverflow)?; + errors.try_reserve(additional)?; + if errors.is_empty() { + errors.push_str("Errors while parsing tags: "); + } else { + errors.push_str(", "); + } + // Writing to String only fails if its formatter does, which it does not. + let _ = write!(errors, "{error}"); + } + } + } + + Ok((!errors.is_empty()).then_some(errors)) + } + + fn push_validated(&mut self, key: &str, value: &str) { + let start = self.storage.len(); + self.storage.push_str(key); + self.storage.push(':'); + self.storage.push_str(value); + let end = self.storage.len(); + self.storage.push(','); + self.tags.push(start..end); + } + + fn push_serialized_validated(&mut self, tag: &str) { + let start = self.storage.len(); + self.storage.push_str(tag); + let end = self.storage.len(); + self.storage.push(','); + self.tags.push(start..end); + } + + pub(crate) fn iter(&self) -> impl ExactSizeIterator { + self.tags.iter().map(|range| { + // SAFETY: ranges are private and recorded only at UTF-8 boundaries + // immediately after appending a complete tag to `storage`. + unsafe { self.storage.get_unchecked(range.clone()) } + }) + } + + pub(crate) fn len(&self) -> usize { + self.tags.len() + } +} + +/// The final Unified Service Tags for one sample, with inline end offsets. +#[derive(Debug, Default)] +pub(crate) struct UnifiedServiceTagSegment { + storage: String, + ends: [u32; 3], + len: u8, +} + +impl PartialEq for UnifiedServiceTagSegment { + fn eq(&self, other: &Self) -> bool { + // End offsets are iteration metadata for this serialized identity. + self.storage == other.storage + } +} + +impl Eq for UnifiedServiceTagSegment {} + +impl Hash for UnifiedServiceTagSegment { + fn hash(&self, state: &mut H) { + self.storage.hash(state); + } +} + +impl UnifiedServiceTagSegment { + pub(crate) fn try_new( + service: &str, + env: &str, + version: &str, + ) -> Result { + let mut values = [("", ""); 3]; + let mut len = 0usize; + let mut storage_len = 0usize; + for (key, value) in [("service", service), ("env", env), ("version", version)] { + if value.is_empty() { + continue; + } + Tag::validate(key, value)?; + storage_len = storage_len + .checked_add(key.len()) + .and_then(|len| len.checked_add(value.len())) + .and_then(|len| len.checked_add(":,".len())) + .ok_or(ProfileTagError::CapacityOverflow)?; + values[len] = (key, value); + len += 1; + } + if storage_len > u32::MAX as usize { + return Err(ProfileTagError::CapacityOverflow); + } + + let mut segment = Self::default(); + segment.storage.try_reserve(storage_len)?; + for (key, value) in &values[..len] { + segment.storage.push_str(key); + segment.storage.push(':'); + segment.storage.push_str(value); + segment.ends[segment.len as usize] = segment.storage.len() as u32; + segment.len += 1; + segment.storage.push(','); + } + Ok(segment) + } + + pub(crate) fn iter(&self) -> impl ExactSizeIterator { + (0..self.len as usize).map(|index| { + let start = if index == 0 { + 0 + } else { + self.ends[index - 1] as usize + 1 + }; + let end = self.ends[index] as usize; + // SAFETY: offsets are recorded at UTF-8 boundaries and each + // preceding end is followed by exactly one comma. + unsafe { self.storage.get_unchecked(start..end) } + }) + } + + pub(crate) fn len(&self) -> usize { + self.len as usize + } + + #[cfg(any(target_os = "linux", test))] + pub(crate) fn matches(&self, service: &str, env: &str, version: &str) -> bool { + let mut expected = [("", ""); 3]; + let mut len = 0usize; + for (key, value) in [("service", service), ("env", env), ("version", version)] { + if !value.is_empty() { + expected[len] = (key, value); + len += 1; + } + } + if self.len() != len { + return false; + } + + self.iter() + .zip(&expected[..len]) + .all(|(serialized, (key, value))| { + let key = *key; + let value = *value; + serialized.len() == key.len() + 1 + value.len() + && serialized.as_bytes().get(key.len()) == Some(&b':') + && serialized.starts_with(key) + && serialized[key.len() + 1..] == *value + }) + } +} + +/// The complete, immutable profile tags for one sample's profile identity. +#[derive(Debug, Eq, PartialEq, Hash)] +pub struct ProfileTags { + pub(crate) common: Arc, + pub(crate) unified_service: Arc, + pub(crate) git: Option>, + pub(crate) custom: Option>, +} + +impl ProfileTags { + pub(crate) fn iter(&self) -> impl Iterator { + self.common + .iter() + .chain(self.unified_service.iter()) + .chain(self.git.iter().flat_map(|segment| segment.iter())) + .chain(self.custom.iter().flat_map(|segment| segment.iter())) + } + + pub(crate) fn try_materialize(&self) -> anyhow::Result> { + let capacity = self + .common + .len() + .checked_add(self.unified_service.len()) + .and_then(|len| len.checked_add(self.git.as_ref().map_or(0, |segment| segment.len()))) + .and_then(|len| { + len.checked_add(self.custom.as_ref().map_or(0, |segment| segment.len())) + }) + .ok_or(ProfileTagError::CapacityOverflow)?; + let mut tags = Vec::new(); + tags.try_reserve_exact(capacity)?; + + for serialized in self.iter() { + if let Some((key, value)) = serialized.split_once(':') { + tags.push(Tag::new(key, value)?); + } else { + let (mut parsed, error) = parse_tags(serialized); + if let Some(error) = error { + anyhow::bail!(error); + } + tags.append(&mut parsed); + } + } + Ok(tags) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialized_storage_defines_segment_identity() { + use std::collections::hash_map::DefaultHasher; + + let one_tag = ProfileTagSegment::try_from_kv_slice(&[("a", "b,c:d")]).unwrap(); + let two_tags = ProfileTagSegment::try_from_kv_slice(&[("a", "b"), ("c", "d")]).unwrap(); + assert_eq!(one_tag.storage, two_tags.storage); + assert_eq!(one_tag, two_tags); + + let hash = |segment: &ProfileTagSegment| { + let mut hasher = DefaultHasher::new(); + segment.hash(&mut hasher); + hasher.finish() + }; + assert_eq!(hash(&one_tag), hash(&two_tags)); + } + + #[test] + fn unified_service_tags_use_inline_offsets_and_omit_empty_values() { + let segment = UnifiedServiceTagSegment::try_new("checkout", "", "1.2.3").unwrap(); + + assert_eq!(segment.storage, "service:checkout,version:1.2.3,"); + assert!(segment.matches("checkout", "", "1.2.3")); + assert!(!segment.matches("checkout", "staging", "1.2.3")); + assert_eq!( + segment.iter().collect::>(), + ["service:checkout", "version:1.2.3"] + ); + } + + #[test] + fn segment_uses_one_comma_separated_string() { + let mut segment = + ProfileTagSegment::try_from_kv_slice(&[("service", "checkout"), ("env", "production")]) + .unwrap(); + segment.try_push_tags("standalone").unwrap(); + + assert_eq!( + segment.storage, + "service:checkout,env:production,standalone," + ); + assert_eq!( + segment.iter().collect::>(), + ["service:checkout", "env:production", "standalone"] + ); + } +} diff --git a/profiling/src/profiling/mod.rs b/profiling/src/profiling/mod.rs index 574320c166..f2efb24529 100644 --- a/profiling/src/profiling/mod.rs +++ b/profiling/src/profiling/mod.rs @@ -18,20 +18,23 @@ 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::{Clocks, RefCellExt, CLOCKS, REQUEST_LOCALS, TAGS}; +#[cfg(target_os = "linux")] +use crate::process_context::{ProcessIdentityRef, ThreadContextRead}; +use crate::profile_tags::ProfileTags; +use crate::{Clocks, RefCellExt, CLOCKS, GLOBAL_TAGS, REQUEST_LOCALS}; use chrono::Utc; use core::mem::forget; use core::{ptr, str}; use cpu_time::ThreadTime; use crossbeam_channel::{Receiver, Sender, TrySendError}; use dashmap::DashMap; -use libdd_common::tag::Tag; use libdd_profiling::api::{ Function, Label as ApiLabel, Location, Period, Sample, SampleType as ApiSampleType, UpscalingInfo, ValueType as ApiValueType, @@ -43,6 +46,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 +170,25 @@ pub struct Label { pub value: LabelValue, } +struct SampleLabels { + labels: Vec