diff --git a/Cargo.lock b/Cargo.lock index e99f11eddf4..8d11d7c2986 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,9 +118,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.98" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" @@ -1325,11 +1325,13 @@ dependencies = [ "libdd-common-ffi", "libdd-crashtracker-ffi", "libdd-data-pipeline", + "libdd-library-config", "libdd-library-config-ffi", "libdd-remote-config", "libdd-telemetry", "libdd-telemetry-ffi", "libdd-tinybytes", + "libdd-trace-protobuf", "libdd-trace-stats", "libdd-trace-utils", "log", @@ -1372,6 +1374,7 @@ dependencies = [ "libc 0.2.177", "libdd-alloc", "libdd-common", + "libdd-library-config", "libdd-library-config-ffi", "libdd-profiling", "log", @@ -2779,7 +2782,7 @@ dependencies = [ [[package]] name = "libdd-common" -version = "4.2.0" +version = "5.0.0" dependencies = [ "anyhow", "bytes", @@ -2811,6 +2814,7 @@ dependencies = [ "rustls", "rustls-native-certs", "rustls-platform-verifier", + "rustls-webpki", "serde", "static_assertions", "tempfile", @@ -2923,6 +2927,7 @@ dependencies = [ "libdd-trace-protobuf", "libdd-trace-stats", "libdd-trace-utils", + "prost", "rand 0.8.5", "regex", "rmp-serde", @@ -2967,6 +2972,7 @@ version = "2.0.0" dependencies = [ "anyhow", "libc 0.2.177", + "libdd-alloc", "libdd-trace-protobuf", "memfd", "prost", @@ -2977,6 +2983,7 @@ dependencies = [ "serde_yaml", "serial_test", "tempfile", + "windows-sys 0.52.0", ] [[package]] @@ -3071,7 +3078,7 @@ dependencies = [ [[package]] name = "libdd-remote-config" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -3231,7 +3238,9 @@ dependencies = [ "libdd-capabilities-impl", "libdd-common", "libdd-ddsketch", + "libdd-dogstatsd-client", "libdd-shared-runtime", + "libdd-telemetry", "libdd-trace-obfuscation", "libdd-trace-protobuf", "libdd-trace-utils", @@ -3257,6 +3266,7 @@ dependencies = [ "flate2", "futures", "getrandom 0.2.15", + "hex", "http", "http-body", "http-body-util", @@ -3277,6 +3287,7 @@ dependencies = [ "rmpv", "rustc-hash 2.1.2", "serde", + "serde-transcode", "serde_json", "tempfile", "thin-vec", @@ -4852,9 +4863,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", @@ -5047,6 +5058,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde-transcode" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590c0e25c2a5bb6e85bf5c1bce768ceb86b316e7a01bdf07d2cb4ec2271990e2" +dependencies = [ + "serde", +] + [[package]] name = "serde_bytes" version = "0.11.15" diff --git a/appsec/cmake/ddtrace.cmake b/appsec/cmake/ddtrace.cmake index 8e0f3092cd4..3bcfcfb5000 100644 --- a/appsec/cmake/ddtrace.cmake +++ b/appsec/cmake/ddtrace.cmake @@ -1,4 +1,5 @@ include(ExternalProject) +include(CheckCCompilerFlag) set(CARGO_BUILD_CMD "cargo build") set(CARGO_BUILD_ENV "") # Initialize to empty @@ -34,9 +35,11 @@ add_custom_target(ddtrace_exports elseif(APPLE) set(EXPORTS_FILE "${CMAKE_BINARY_DIR}/datadog_exports.sym") add_custom_target(ddtrace_exports - COMMAND sed "s/^/_/" "${CMAKE_SOURCE_DIR}/../datadog.sym" > "${EXPORTS_FILE}" + # otel_thread_ctx_v1 is a Linux-only TLS symbol. + COMMAND bash -c "grep -v ^otel_thread_ctx_v1$ '${CMAKE_SOURCE_DIR}'/../datadog.sym | sed 's/^/_/' > '${EXPORTS_FILE}'" BYPRODUCTS ${EXPORTS_FILE} DEPENDS ${CMAKE_SOURCE_DIR}/../datadog.sym + VERBATIM ) endif() @@ -92,6 +95,8 @@ file(GLOB_RECURSE FILES_DDTRACE CONFIGURE_DEPENDS "${CMAKE_SOURCE_DIR}/../ext/*.c" "${CMAKE_SOURCE_DIR}/../ext/**/*.c" + "${CMAKE_SOURCE_DIR}/../tracer/*.c" + "${CMAKE_SOURCE_DIR}/../tracer/**/*.c" "${CMAKE_SOURCE_DIR}/../zend_abstract_interface/*.c" "${CMAKE_SOURCE_DIR}/../zend_abstract_interface/**/*.c" ) @@ -103,14 +108,14 @@ list(APPEND FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../components/string_view/string_view.c" ) if (PhpConfig_VERNUM GREATER_EQUAL 80000) - list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../ext/handlers_curl_php7.c" + list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../tracer/handlers_curl_php7.c" "${CMAKE_SOURCE_DIR}/../zend_abstract_interface/interceptor/php7/interceptor.c" "${CMAKE_SOURCE_DIR}/../zend_abstract_interface/interceptor/php7/resolver.c" "${CMAKE_SOURCE_DIR}/../zend_abstract_interface/sandbox/php7/sandbox.c") else() # PHP 7 - list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../ext/handlers_curl.c" - "${CMAKE_SOURCE_DIR}/../ext/hook/uhook_attributes.c" - "${CMAKE_SOURCE_DIR}/../ext/hook/uhook_otel.c" + list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../tracer/handlers_curl.c" + "${CMAKE_SOURCE_DIR}/../tracer/hook/uhook_attributes.c" + "${CMAKE_SOURCE_DIR}/../tracer/hook/uhook_otel.c" "${CMAKE_SOURCE_DIR}/../zend_abstract_interface/interceptor/php8/interceptor.c" "${CMAKE_SOURCE_DIR}/../zend_abstract_interface/interceptor/php8/resolver.c" "${CMAKE_SOURCE_DIR}/../zend_abstract_interface/interceptor/php8/resolver_pre-8_2.c" @@ -118,13 +123,13 @@ else() # PHP 7 "${CMAKE_SOURCE_DIR}/../zend_abstract_interface/sandbox/php8/sandbox.c") endif() if (PhpConfig_VERNUM LESS 80200) - list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../ext/weakrefs.c") + list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../tracer/weakrefs.c") list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../zend_abstract_interface/interceptor/php8/resolver.c") else() # PHP 8.2+ list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../zend_abstract_interface/interceptor/php8/resolver_pre-8_2.c") endif() if (PhpConfig_VERNUM LESS 80100) - list(REMOVE_ITEM FILES_DDTRACE "${CMAKE_SOURCE_DIR}/../ext/handlers_fiber.c") + 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") @@ -162,7 +167,13 @@ endif() if(CURL_DEFINITIONS) target_compile_definitions(ddtrace PRIVATE ${CURL_DEFINITIONS}) endif() -target_compile_definitions(ddtrace PRIVATE ZEND_ENABLE_STATIC_TSRMLS_CACHE=1 COMPILE_DL_DDTRACE=1) +target_compile_definitions(ddtrace PRIVATE ZEND_ENABLE_STATIC_TSRMLS_CACHE=1 COMPILE_DL_DDTRACE=1 DDTRACE) +if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux" AND CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$") + check_c_compiler_flag("-mtls-dialect=gnu2" DDTRACE_HAS_GNU2_TLS_DIALECT) + if(DDTRACE_HAS_GNU2_TLS_DIALECT) + target_compile_options(ddtrace PRIVATE -mtls-dialect=gnu2) + endif() +endif() target_include_directories(ddtrace PRIVATE ${CURL_INCLUDE_DIRS} ${CMAKE_SOURCE_DIR}/.. @@ -171,6 +182,8 @@ target_include_directories(ddtrace PRIVATE ${CMAKE_SOURCE_DIR}/../ext ${CMAKE_SOURCE_DIR}/../ext/vendor ${CMAKE_SOURCE_DIR}/../ext/vendor/mt19937 + ${CMAKE_SOURCE_DIR}/../tracer + ${CMAKE_SOURCE_DIR}/../tracer/vendor ${CMAKE_BINARY_DIR}/gen_ddtrace ) add_dependencies(ddtrace ddtrace_exports update_version_h) diff --git a/appsec/src/extension/ddappsec.c b/appsec/src/extension/ddappsec.c index 9e07e9b2477..6e05567de8e 100644 --- a/appsec/src/extension/ddappsec.c +++ b/appsec/src/extension/ddappsec.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -55,6 +56,7 @@ static atomic_int _thread_count; static void _check_enabled(void); #ifdef TESTING static void _register_testing_objects(void); +volatile int ddappsec_debugger_wait_continue; #endif static PHP_MINIT_FUNCTION(ddappsec); @@ -481,6 +483,32 @@ static PHP_FUNCTION(datadog_appsec_testing_stop_for_debugger) RETURN_TRUE; } +static PHP_FUNCTION(datadog_appsec_testing_wait_for_debugger) +{ + if (zend_parse_parameters_none() == FAILURE) { + RETURN_FALSE; + } + ddappsec_debugger_wait_continue = 0; + + int fd = open( + "/tmp/pid", O_CREAT | O_WRONLY | O_TRUNC | O_CLOEXEC, 0600); // NOLINT + if (fd < 0) { + RETURN_FALSE; + } + char pid[sizeof("-2147483648")] = ""; + sprintf(pid, "%" PRIi32, (int32_t)getpid()); // NOLINT + ATTR_UNUSED ssize_t unused_ = write(fd, pid, strlen(pid)); + close(fd); + + while (!ddappsec_debugger_wait_continue) { + usleep(10000); // NOLINT + } + ddappsec_debugger_wait_continue = 0; + unlink("/tmp/pid"); // NOLINT + + RETURN_TRUE; +} + static PHP_FUNCTION(datadog_appsec_testing_request_exec) { zend_array *data = NULL; @@ -632,6 +660,7 @@ static const zend_function_entry testing_request_control_functions[] = { ZEND_RAW_FENTRY(DD_TESTING_NS "rinit", PHP_FN(datadog_appsec_testing_rinit), void_ret_bool_arginfo, 0, NULL, NULL) ZEND_RAW_FENTRY(DD_TESTING_NS "rshutdown", PHP_FN(datadog_appsec_testing_rshutdown), void_ret_bool_arginfo, 0, NULL, NULL) ZEND_RAW_FENTRY(DD_TESTING_NS "request_exec", PHP_FN(datadog_appsec_testing_request_exec), request_exec_arginfo, 0, NULL, NULL) + ZEND_RAW_FENTRY(DD_TESTING_NS "wait_for_debugger", PHP_FN(datadog_appsec_testing_wait_for_debugger), void_ret_bool_arginfo, 0, NULL, NULL) PHP_FE_END }; static const zend_function_entry testing_functions[] = { diff --git a/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/mock_agent/TelemetryHandler.groovy b/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/mock_agent/TelemetryHandler.groovy index 80d4737993c..e47b19f09dc 100644 --- a/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/mock_agent/TelemetryHandler.groovy +++ b/appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/mock_agent/TelemetryHandler.groovy @@ -23,7 +23,7 @@ class TelemetryHandler implements Handler { ctx.bodyInputStream().withCloseable { message = readTelemetryMessage(it) } - log.debug("Read telemetry message: ${message['request_type']}") + log.debug("Read telemetry message: ${describeTelemetryMessage(message)}") } catch (AssertionError e) { log.error("Error reading traces: $e.message") error = e @@ -52,6 +52,48 @@ class TelemetryHandler implements Handler { jsonSlurper.parse(is) } + private static String describeTelemetryMessage(Object message) { + def application = message['application'] ?: [:] + def requestType = message['request_type'] + def payload = message['payload'] + def details = [ + "request_type=${requestType}", + "seq_id=${message['seq_id']}", + "service=${application['service_name']}", + "runtime_id=${application['runtime_id']}", + ] + def payloadSummary = describeTelemetryPayload(requestType, payload) + if (payloadSummary) { + details << "payload=${payloadSummary}" + } + details.join(', ') + } + + private static String describeTelemetryPayload(String requestType, Object payload) { + if (requestType == 'message-batch' && payload instanceof List) { + return payload.collect { describeTelemetryPayload(it['request_type'], it['payload']) } + .findAll { it } + .join('; ') + } + + if (!(payload instanceof Map)) { + return null + } + + def fields = [] + if (payload['integrations'] instanceof List) { + fields << "integrations=${payload['integrations'].collect { it['name'] }}" + } + if (payload['dependencies'] instanceof List) { + fields << "dependencies=${payload['dependencies'].size()}" + } + if (payload['configuration'] instanceof List) { + fields << "configuration=${payload['configuration'].size()}" + } + + return "${requestType}{${fields.join(', ')}}" + } + List drain(long timeoutInMs) { synchronized (capturedTelemetryMessages) { if (!savedError && capturedTelemetryMessages.isEmpty()) { diff --git a/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/OtelThreadContextTests.groovy b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/OtelThreadContextTests.groovy new file mode 100644 index 00000000000..f4e100c3834 --- /dev/null +++ b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/OtelThreadContextTests.groovy @@ -0,0 +1,698 @@ +package com.datadog.appsec.php.integration + +import com.datadog.appsec.php.docker.AppSecContainer +import com.datadog.appsec.php.docker.InspectContainerHelper +import groovy.json.JsonSlurper +import groovy.util.logging.Slf4j +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.condition.DisabledIf +import org.testcontainers.containers.Container.ExecResult +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers + +import java.net.http.HttpResponse +import java.util.concurrent.CompletableFuture +import java.util.concurrent.TimeUnit + +import static com.datadog.appsec.php.integration.TestParams.getPhpVersion +import static com.datadog.appsec.php.integration.TestParams.getVariant +import static java.net.http.HttpResponse.BodyHandlers.ofString + +@Testcontainers +@Slf4j +@DisabledIf('isDisabled') +class OtelThreadContextTests { + private static final String PID_FILE = '/tmp/pid' + private static final String PHASE_FILE = '/tmp/otel_context_phase' + private static final String GDB_SCRIPT = + '/project/appsec/tests/integration/src/test/resources/otel_context_gdb.py' + private static final String GDB_TIMEOUT = '20s' + private static final String DISTRIBUTED_TRACE_ID = '11111111111111112222222222222222' + private static final String THREADLOCAL_ATTRIBUTE_KEY_MAP = [ + 'datadog.local_root_span_id', + 'service.name', + 'service.version', + 'deployment.environment.name', + ].join(',') + + static boolean disabled = phpVersion != '8.3' + + @Container + public static final AppSecContainer CONTAINER = + new AppSecContainer( + workVolume: this.name, + baseTag: 'apache2-mod-php', + phpVersion: phpVersion, + phpVariant: variant, + www: 'base', + ) + .withEnv('DD_TRACE_REPORT_HOSTNAME', 'true') + .withEnv('DD_SERVICE', 'otel-thread-context-service') + .withEnv('DD_VERSION', '1.2.3') + .withEnv('DD_ENV', 'otel-thread-context-env') + + static void main(String[] args) { + InspectContainerHelper.run(CONTAINER) + } + + /** + * Baseline request-root check: the auto-created request span should publish a + * complete OTel TLS record before any explicit stack or context manipulation. + */ + @Test + void 'otel thread context matches trace ids during regular request lifecycle'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/regular.php') + boolean requestContinued = false + + try { + Map threadContext = inspectThreadLocalAndContinue(pausedRequest.pid) + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assertThreadContextMatchesResponse(threadContext, responseBody) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + /** + * UserRequest creates a separate local root stack. This protects the expectation + * that trace/span ids follow that stack while resource attrs keep following the + * entrypoint root semantics used for sidecar/process context publication. + */ + @Test + void 'otel thread context matches trace ids during user request lifecycle'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/user_request.php') + boolean requestContinued = false + + try { + Map threadContext = inspectThreadLocalAndContinue(pausedRequest.pid) + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + // local_root_span_id follows the currently active stack's root span. + assert responseBody.local_root_span_id == responseBody.span_id + assert responseBody.service_name != responseBody.user_request_service_name + assert responseBody.service_version != responseBody.user_request_service_version + assert responseBody.deployment_environment_name != + responseBody.user_request_deployment_environment_name + assertThreadContextMatchesResponse(threadContext, responseBody) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + /** + * Runtime config mutation goes through the INI alter callbacks in ddtrace.c. + * The OTel attrs should follow those effective service/env/version values. + */ + @Test + void 'otel thread context reflects mid request service env and version ini changes'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/runtime_service_env_changes.php') + boolean requestContinued = false + + try { + Map threadContext = inspectThreadLocalAndContinue(pausedRequest.pid) + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert responseBody.service_name == 'otel-thread-context-updated-service' + assert responseBody.service_version == '2.3.4' + assert responseBody.deployment_environment_name == 'otel-thread-context-updated-env' + assertThreadContextMatchesResponse(threadContext, responseBody) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + /** + * Entrypoint root property writes also feed the sidecar-facing root span data. + * This verifies the same service/env/version values are reflected in OTel TLS. + */ + @Test + void 'otel thread context reflects root span service env and version changes'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/root_span_service_env_changes.php') + boolean requestContinued = false + + try { + Map threadContext = inspectThreadLocalAndContinue(pausedRequest.pid) + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert responseBody.service_name == 'otel-thread-context-root-service' + assert responseBody.service_version == '3.4.5' + assert responseBody.deployment_environment_name == 'otel-thread-context-root-env' + assertThreadContextMatchesResponse(threadContext, responseBody) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + /** + * Nested roots may have their own mutable service/env values, but OTel attrs are + * expected to mirror entrypoint/sidecar semantics rather than the nested root. + */ + @Test + void 'otel thread context keeps entrypoint root service env on nested root changes'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/nested_root_service_env_changes.php') + String cleanupPid = pausedRequest.pid + boolean requestContinued = false + + try { + Map entrypointUpdatedContext = + inspectThreadLocalAndContinue(pausedRequest.pid) + + String nestedRestoredPid = waitForPausedPid( + pausedRequest.responseFuture, + 'nested-restored') + cleanupPid = nestedRestoredPid + Map nestedRestoredContext = + inspectThreadLocalAndContinue(nestedRestoredPid) + + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert responseBody.entrypoint_updated_waited == true + assert responseBody.nested_restored_waited == true + assert responseBody.original_service_name != responseBody.entrypoint_service_name + assert responseBody.original_deployment_environment_name != responseBody.entrypoint_deployment_environment_name + assert responseBody.nested_service_name != responseBody.entrypoint_service_name + assert responseBody.nested_deployment_environment_name != responseBody.entrypoint_deployment_environment_name + + [entrypointUpdatedContext, nestedRestoredContext].each { threadContext -> + assert threadContext['service.name'] == responseBody.entrypoint_service_name + assert threadContext['service.name'] != responseBody.original_service_name + assert threadContext['service.name'] != responseBody.nested_service_name + assert threadContext['deployment.environment.name'] == responseBody.entrypoint_deployment_environment_name + assert threadContext['deployment.environment.name'] != responseBody.original_deployment_environment_name + assert threadContext['deployment.environment.name'] != responseBody.nested_deployment_environment_name + + assertThreadContextMatchesResponse(threadContext, responseBody) + } + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(cleanupPid) + } + } + } + + /** + * Header consumption updates an already-active root trace id through + * ddtrace_apply_distributed_tracing_result(); this guards that OTel is republished. + */ + @Test + void 'otel thread context reflects trace id changes from distributed tracing'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/distributed_tracing.php') + boolean requestContinued = false + + try { + Map threadContext = inspectThreadLocalAndContinue(pausedRequest.pid) + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert responseBody.original_trace_id != DISTRIBUTED_TRACE_ID + assert threadContext.trace_id == DISTRIBUTED_TRACE_ID + assertThreadContextMatchesResponse(threadContext, responseBody) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + /** + * Manual distributed tracing context bypasses header extraction and mutates the + * active root directly. This covers the separate setter path for OTel trace ids. + */ + @Test + void 'otel thread context reflects trace id changes from manual distributed tracing context'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/set_distributed_tracing_context.php') + boolean requestContinued = false + + try { + Map threadContext = inspectThreadLocalAndContinue(pausedRequest.pid) + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert responseBody.original_trace_id != DISTRIBUTED_TRACE_ID + assert responseBody.trace_id == DISTRIBUTED_TRACE_ID + assert threadContext.trace_id == DISTRIBUTED_TRACE_ID + assertThreadContextMatchesResponse(threadContext, responseBody) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + /** + * Direct RootSpanData::$traceId writes go through the PHP property handler, not + * the distributed tracing APIs. The TLS trace id must still be updated. + */ + @Test + void 'otel thread context reflects direct root span trace id changes'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/root_trace_id_changes.php') + boolean requestContinued = false + + try { + Map threadContext = inspectThreadLocalAndContinue(pausedRequest.pid) + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert responseBody.original_trace_id != DISTRIBUTED_TRACE_ID + assert threadContext.trace_id == DISTRIBUTED_TRACE_ID + assertThreadContextMatchesResponse(threadContext, responseBody) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + /** + * Starting a child span should only advance the active span id in the attached + * record; trace id and local root span id should remain tied to the root. + */ + @Test + void 'otel thread context reflects active child span id changes'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/subspan.php') + boolean requestContinued = false + + try { + Map threadContext = inspectThreadLocalAndContinue(pausedRequest.pid) + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert threadContext.span_id == responseBody.child_span_id + assert responseBody.span_id != responseBody.local_root_span_id + assert responseBody.trace_id == responseBody.root_trace_id + assertThreadContextMatchesResponse(threadContext, responseBody) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + /** + * Closing the active child span exercises the close path that moves stack->active + * back to the parent and republishes that parent span id into OTel TLS. + */ + @Test + void 'otel thread context restores parent span id after child span close'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/closed_subspan.php') + boolean requestContinued = false + + try { + Map threadContext = inspectThreadLocalAndContinue(pausedRequest.pid) + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert responseBody.child_span_id != responseBody.span_id + assert threadContext.span_id == responseBody.local_root_span_id + assertThreadContextMatchesResponse(threadContext, responseBody) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + /** + * Dropping the active child span uses a separate stack mutation path from close. + * This guards the OTel parent-span restoration in ddtrace_drop_span(). + */ + @Test + void 'otel thread context restores parent span id after child span drop'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/dropped_subspan.php') + boolean requestContinued = false + + try { + Map threadContext = inspectThreadLocalAndContinue(pausedRequest.pid) + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert responseBody.dropped == true + assert responseBody.child_span_id != responseBody.span_id + assert threadContext.span_id == responseBody.local_root_span_id + assertThreadContextMatchesResponse(threadContext, responseBody) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + /** + * Explicit stack switches should detach TLS when the target stack has no active + * root, then republish the correct root record when switching between root stacks. + */ + @Test + void 'otel thread context detaches on empty stack and republishes on explicit stack switch'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/stack_switches.php') + String cleanupPid = pausedRequest.pid + boolean requestContinued = false + + try { + Map emptyContext = inspectThreadLocalAndContinue(pausedRequest.pid) + assert emptyContext.ctx == '0x0' + + String restoredEntrypointPid = waitForPausedPid( + pausedRequest.responseFuture, + 'entrypoint-restored') + cleanupPid = restoredEntrypointPid + Map restoredEntrypointContext = + inspectThreadLocalAndContinue(restoredEntrypointPid) + + String switchedEntrypointPid = waitForPausedPid( + pausedRequest.responseFuture, + 'entrypoint-switched') + cleanupPid = switchedEntrypointPid + Map switchedEntrypointContext = + inspectThreadLocalAndContinue(switchedEntrypointPid) + + String switchedNestedPid = waitForPausedPid( + pausedRequest.responseFuture, + 'nested-switched') + cleanupPid = switchedNestedPid + Map switchedNestedContext = + inspectThreadLocalAndContinue(switchedNestedPid) + + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert responseBody.empty_waited == true + assert responseBody.entrypoint_restored_waited == true + assert responseBody.entrypoint_switched_waited == true + assert responseBody.nested_switched_waited == true + assert responseBody.entrypoint_trace_id != responseBody.nested_trace_id + assert responseBody.entrypoint_span_id != responseBody.nested_span_id + + assertThreadContextMatchesStack(restoredEntrypointContext, responseBody, 'entrypoint') + assertThreadContextMatchesStack(switchedEntrypointContext, responseBody, 'entrypoint') + assertThreadContextMatchesStack(switchedNestedContext, responseBody, 'nested') + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(cleanupPid) + } + } + } + + /** + * Fiber switches store and restore DDTrace active stacks through the fiber + * observer. This verifies OTel TLS follows the fiber's root, then the main root. + */ + @Test + void 'otel thread context follows fiber and main stack switches'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/fiber_switch.php') + String cleanupPid = pausedRequest.pid + boolean requestContinued = false + + try { + Map fiberContext = inspectThreadLocalAndContinue(pausedRequest.pid) + + String mainPid = waitForPausedPid(pausedRequest.responseFuture, 'main') + cleanupPid = mainPid + Map mainContext = inspectThreadLocalAndContinue(mainPid) + + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert responseBody.fiber_waited == true + assert responseBody.main_waited == true + assert responseBody.main_trace_id != responseBody.fiber_trace_id + assert responseBody.main_span_id != responseBody.fiber_span_id + + assertThreadContextMatchesStack(fiberContext, responseBody, 'fiber') + assertThreadContextMatchesStack(mainContext, responseBody, 'main') + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(cleanupPid) + } + } + } + + /** + * Runtime disable tears down tracing state and should null the TLS pointer. + * Re-enabling in the same request should attach a fresh root context again. + */ + @Test + void 'otel thread context detaches when tracing is disabled and reattaches when reenabled'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/reenable.php') + String cleanupPid = pausedRequest.pid + boolean requestContinued = false + + try { + Map disabledContext = inspectThreadLocalAndContinue(pausedRequest.pid) + assert disabledContext.ctx == '0x0' + + String reenabledPid = waitForPausedPid(pausedRequest.responseFuture, 'reenabled') + cleanupPid = reenabledPid + Map reenabledContext = inspectThreadLocalAndContinue(reenabledPid) + requestContinued = true + HttpResponse response = awaitResponse(pausedRequest) + Map responseBody = parseJsonResponse(response) + + assert responseBody.disabled_waited == true + assertThreadContextMatchesResponse(reenabledContext, responseBody) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(cleanupPid) + } + } + } + + /** + * Process context is shared separately from per-thread TLS. This verifies the + * process-level metadata and the advertised thread-local attribute key map. + */ + @Test + void 'otel process context shared memory has expected metadata and threadlocal attributes'() { + PausedRequest pausedRequest = startPausedRequest('/otel_context/regular.php') + boolean requestContinued = false + + try { + Map processContext = inspectProcessContext(pausedRequest.pid) + continuePausedRequest(pausedRequest.pid) + requestContinued = true + + HttpResponse response = awaitResponse(pausedRequest) + parseJsonResponse(response) + + assert processContext.present == 'true' + assert processContext.signature == 'OTEL_CTX' + assert processContext.version == '2' + assert processContext.payload_size.toInteger() > 0 + assert processContext.published_at.toBigInteger() > 0 + assert processContext['telemetry.sdk.language'] == 'php' + assert processContext['telemetry.sdk.version'] == expectedTracerVersion() + assert processContext['host.name'] == expectedContainerHostname() + assert processContext['threadlocal.schema_version'] == 'tlsdesc_v1_dev' + assert processContext['threadlocal.attribute_key_map'] == THREADLOCAL_ATTRIBUTE_KEY_MAP + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + private static void assertThreadContextMatchesResponse( + Map threadContext, Map responseBody) { + assert responseBody.waited == true + assert threadContext.ctx != '0x0' + assert threadContext.valid == '1' + assert threadContext.attrs_data_size.toInteger() >= 18 + assert threadContext.trace_id == responseBody.trace_id + assert threadContext.span_id == responseBody.span_id + assert threadContext['datadog.local_root_span_id'] == responseBody.local_root_span_id + assert threadContext['service.name'] == responseBody.service_name + assert threadContext['service.version'] == responseBody.service_version + assert threadContext['deployment.environment.name'] == responseBody.deployment_environment_name + } + + private static void assertThreadContextMatchesStack( + Map threadContext, Map responseBody, String stackPrefix) { + assert threadContext.ctx != '0x0' + assert threadContext.valid == '1' + assert threadContext.attrs_data_size.toInteger() >= 18 + assert threadContext.trace_id == responseBody["${stackPrefix}_trace_id"] + assert threadContext.span_id == responseBody["${stackPrefix}_span_id"] + assert threadContext['datadog.local_root_span_id'] == + responseBody["${stackPrefix}_local_root_span_id"] + assert threadContext['service.name'] == responseBody.service_name + assert threadContext['service.version'] == responseBody.service_version + assert threadContext['deployment.environment.name'] == responseBody.deployment_environment_name + } + + private static Map parseJsonResponse(HttpResponse response) { + assert response.statusCode() == 200 + new JsonSlurper().parseText(response.body()) as Map + } + + private static PausedRequest startPausedRequest(String path) { + CONTAINER.execInContainer('rm', '-f', PID_FILE, PHASE_FILE) + + def request = CONTAINER.buildReq(path).GET().build() + CompletableFuture> responseFuture = + CONTAINER.httpClient.sendAsync(request, ofString()) + + new PausedRequest( + pid: waitForPausedPid(responseFuture), + responseFuture: responseFuture) + } + + private static String waitForPausedPid( + CompletableFuture> responseFuture, + String expectedPhase = null) { + long deadline = System.currentTimeMillis() + 15_000 + + while (System.currentTimeMillis() < deadline) { + if (responseFuture.isDone()) { + HttpResponse response = responseFuture.getNow(null) + throw new AssertionError( + "Request completed before the debugger pause: HTTP ${response.statusCode()}\n${response.body()}".toString()) + } + + ExecResult res = CONTAINER.execInContainer( + 'bash', '-lc', + waitForPausedPidCommand(expectedPhase)) + if (res.exitCode == 0) { + String pid = res.stdout.trim() + if (pid) { + return pid + } + } + Thread.sleep(100) + } + + throw new AssertionError('Timed out waiting for the paused PHP worker pid') + } + + private static String waitForPausedPidCommand(String expectedPhase) { + if (expectedPhase == null) { + return "test -s ${PID_FILE} && cat ${PID_FILE} || true".toString() + } + + "test -s ${PID_FILE} && test -s ${PHASE_FILE} " + + "&& test \"\$(cat ${PHASE_FILE})\" = '${expectedPhase}' " + + "&& cat ${PID_FILE} || true" + } + + private static HttpResponse awaitResponse(PausedRequest pausedRequest) { + pausedRequest.responseFuture.get(30, TimeUnit.SECONDS) + } + + private static Map inspectThreadLocalAndContinue(String pid) { + List commands = [ + 'set pagination off', + 'otel-thread-context', + 'ddappsec-continue', + 'detach', + 'quit', + ] + + ExecResult res = runGdb(pid, commands) + parseKeyValueOutput(res.stdout) + } + + private static void continuePausedRequest(String pid) { + runGdb(pid, [ + 'set pagination off', + 'ddappsec-continue', + 'detach', + 'quit', + ]) + } + + private static void continuePausedRequestQuietly(String pid) { + try { + continuePausedRequest(pid) + } catch (Throwable ignored) { + // The original failure is more useful than a best-effort cleanup error. + } + } + + private static ExecResult runGdb(String pid, List commands) { + List args = [ + 'timeout', + GDB_TIMEOUT, + 'gdb', + '--batch', + '--quiet', + '-p', + pid, + '-ex', + "python exec(open('${GDB_SCRIPT}').read())".toString(), + ] + commands.each { + args.add('-ex') + args.add(it) + } + + ExecResult res = CONTAINER.execInContainer(args as String[]) + if (res.exitCode != 0 || res.stderr =~ /(Traceback|Python Exception|No symbol|Undefined command)/) { + throw new AssertionError( + "gdb failed with exit code ${res.exitCode}\nstdout:\n${res.stdout}\nstderr:\n${res.stderr}".toString()) + } + res + } + + private static Map inspectProcessContext(String pid) { + ExecResult res = runGdb(pid, [ + 'set pagination off', + 'otel-process-context', + 'detach', + 'quit', + ]) + parseKeyValueOutput(res.stdout) + } + + private static String expectedTracerVersion() { + ExecResult res = CONTAINER.execInContainer('bash', '-lc', 'cat /project/VERSION') + assert res.exitCode == 0 + res.stdout.trim() + } + + private static String expectedContainerHostname() { + ExecResult res = CONTAINER.execInContainer('hostname') + assert res.exitCode == 0 + res.stdout.trim() + } + + private static Map parseKeyValueOutput(String output) { + Map result = [:] + output.readLines().each { String line -> + if (line ==~ /^[A-Za-z_][A-Za-z0-9_.]*=.*/) { + int idx = line.indexOf('=') + result[line.substring(0, idx).trim()] = line.substring(idx + 1).trim() + } + } + result + } + + private static class PausedRequest { + String pid + CompletableFuture> responseFuture + } +} diff --git a/appsec/tests/integration/src/test/resources/otel_context_gdb.py b/appsec/tests/integration/src/test/resources/otel_context_gdb.py new file mode 100644 index 00000000000..51698e16e39 --- /dev/null +++ b/appsec/tests/integration/src/test/resources/otel_context_gdb.py @@ -0,0 +1,361 @@ +import gdb +import struct +import sys + + +WAIT_FLAG = "ddappsec_debugger_wait_continue" +WAIT_FRAME_MARKER = "datadog_appsec_testing_wait_for_debugger" +TLS_SYMBOL = "otel_thread_ctx_v1" +THREAD_CONTEXT_SIZE = 640 +EXPECTED_PROCESS_CONTEXT_MAPPING = "OTEL_CTX" +EXPECTED_PROCESS_CONTEXT_SIGNATURE = b"OTEL_CTX" + + +class OtelThreadContext(gdb.Command): + def __init__(self): + super().__init__("otel-thread-context", gdb.COMMAND_DATA) + + def invoke(self, arg, from_tty): + del arg, from_tty + + select_wait_for_debugger_thread() + slot = find_tls_slot() + print_kv("slot", f"0x{slot:x}" if slot else "0x0") + if slot == 0: + return + + ctx = read_pointer(slot) + print_kv("ctx", f"0x{ctx:x}" if ctx else "0x0") + if ctx == 0: + return + + data = read_memory(ctx, THREAD_CONTEXT_SIZE) + attrs_data_size = struct.unpack_from("> 3 + wire_type = key & 0x07 + + if wire_type == 0: + value, offset = read_varint(data, offset) + elif wire_type == 1: + value = data[offset : offset + 8] + offset += 8 + elif wire_type == 2: + size, offset = read_varint(data, offset) + value = data[offset : offset + size] + offset += size + elif wire_type == 5: + value = data[offset : offset + 4] + offset += 4 + else: + raise ValueError(f"unsupported protobuf wire type {wire_type}") + + yield field_number, wire_type, value + + +def decode_any_value(data): + for field_number, wire_type, value in protobuf_fields(data): + if field_number == 1 and wire_type == 2: + return value.decode("utf-8") + if field_number == 5 and wire_type == 2: + return decode_array_value(value) + + return None + + +def decode_array_value(data): + values = [] + + for field_number, wire_type, value in protobuf_fields(data): + if field_number == 1 and wire_type == 2: + values.append(decode_any_value(value)) + + return values + + +def decode_key_value(data): + key = None + value = None + + for field_number, wire_type, field_value in protobuf_fields(data): + if field_number == 1 and wire_type == 2: + key = field_value.decode("utf-8") + elif field_number == 2 and wire_type == 2: + value = decode_any_value(field_value) + + return key, value + + +def decode_process_context_resource_attributes(data): + attributes = {} + + for field_number, wire_type, value in protobuf_fields(data): + if field_number != 1 or wire_type != 2: + continue + + for resource_field_number, resource_wire_type, resource_value in protobuf_fields( + value + ): + if resource_field_number == 1 and resource_wire_type == 2: + key, attr_value = decode_key_value(resource_value) + if key is not None: + attributes[key] = attr_value + + return attributes + + +def find_process_context_mapping(): + with open(f"/proc/{inferior().pid}/maps", "r", encoding="utf-8") as maps: + for line in maps: + if EXPECTED_PROCESS_CONTEXT_MAPPING not in line: + continue + start, _ = line.split(None, 1)[0].split("-", 1) + return int(start, 16) + + with open(f"/proc/{inferior().pid}/maps", "r", encoding="utf-8") as maps: + for line in maps: + fields = line.split(None, 5) + if len(fields) < 2 or "r" not in fields[1]: + continue + + start, end = fields[0].split("-", 1) + start = int(start, 16) + end = int(end, 16) + if end - start < 32: + continue + + try: + if read_memory(start, 8) == EXPECTED_PROCESS_CONTEXT_SIGNATURE: + return start + except gdb.MemoryError: + continue + return None + + +def read_process_context_attributes(mapping): + header = read_memory(mapping, 32) + signature, version, payload_size, published_at, payload_ptr = struct.unpack( + "<8sIIQQ", header + ) + payload = read_memory(payload_ptr, payload_size) + + return { + "signature": signature.rstrip(b"\0").decode("ascii"), + "version": version, + "payload_size": payload_size, + "published_at": published_at, + "attributes": decode_process_context_resource_attributes(payload), + } + + +def read_threadlocal_attribute_key_map(): + mapping = find_process_context_mapping() + if mapping is None: + return [] + + attribute_key_map = read_process_context_attributes(mapping)["attributes"].get( + "threadlocal.attribute_key_map" + ) + if isinstance(attribute_key_map, list): + return attribute_key_map + return [] + + +def decode_thread_context_attrs(data, attrs_data_size, attribute_key_map): + attrs = {} + offset = 28 + end = offset + attrs_data_size + + while offset + 2 <= end: + key_index = data[offset] + value_length = data[offset + 1] + value_start = offset + 2 + value_end = value_start + value_length + if value_end > end: + break + + if key_index < len(attribute_key_map): + attrs[attribute_key_map[key_index]] = data[value_start:value_end].decode("utf-8") + + offset = value_end + + return attrs + + +def frame_names(thread): + names = [] + thread.switch() + + try: + frame = gdb.newest_frame() + except gdb.error: + return names + + while frame: + try: + name = frame.name() + except gdb.error: + name = None + + if name: + names.append(name) + + try: + frame = frame.older() + except gdb.error: + break + + return names + + +def select_wait_for_debugger_thread(emit=True): + threads = inferior().threads() + if len(threads) == 1: + threads[0].switch() + if emit: + print_kv("thread", threads[0].num) + return + + inspected = [] + for thread in threads: + names = frame_names(thread) + inspected.append(f"{thread.num}:{'|'.join(names[:8])}") + if any(WAIT_FRAME_MARKER in name for name in names): + thread.switch() + if emit: + print_kv("thread", thread.num) + return + + raise gdb.GdbError( + "Could not find thread stopped in wait_for_debugger; inspected " + + "; ".join(inspected) + ) + + +def find_tls_slot(): + slot = call_pointer(f"(void *) &{TLS_SYMBOL}") + if slot: + return slot + + slot = call_pointer(f'(void *) dlsym((void *) 0, "{TLS_SYMBOL}")') + if slot: + return slot + + for objfile in gdb.objfiles(): + if not objfile.filename or not objfile.filename.endswith("ddtrace.so"): + continue + + handle = call_pointer(f'(void *) dlopen("{c_string(objfile.filename)}", 6)') + if handle: + slot = call_pointer(f'(void *) dlsym((void *) {handle}, "{TLS_SYMBOL}")') + if slot: + return slot + + return 0 + + +OtelThreadContext() +OtelProcessContext() +DdappsecContinue() diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/closed_subspan.php b/appsec/tests/integration/src/test/www/base/public/otel_context/closed_subspan.php new file mode 100644 index 00000000000..e24cbd570cd --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/closed_subspan.php @@ -0,0 +1,32 @@ + 'missing root span']); + return; +} + +$childSpan = \DDTrace\start_span(); +if (!$childSpan) { + http_response_code(500); + echo json_encode(['error' => 'missing child span']); + return; +} + +$childSpanId = $childSpan->hexId(); +\DDTrace\close_span(); + +$waited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'waited' => $waited, + 'trace_id' => $rootSpan->traceId, + 'span_id' => $rootSpan->hexId(), + 'child_span_id' => $childSpanId, + 'local_root_span_id' => $rootSpan->hexId(), + 'service_name' => $rootSpan->service, + 'service_version' => $rootSpan->version, + 'deployment_environment_name' => $rootSpan->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/distributed_tracing.php b/appsec/tests/integration/src/test/www/base/public/otel_context/distributed_tracing.php new file mode 100644 index 00000000000..27ba35104c0 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/distributed_tracing.php @@ -0,0 +1,27 @@ + 'missing root span']); + return; +} + +$originalTraceId = $rootSpan->traceId; +\DDTrace\consume_distributed_tracing_headers([ + 'traceparent' => '00-11111111111111112222222222222222-3333333333333333-01', +]); + +$waited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'waited' => $waited, + 'original_trace_id' => $originalTraceId, + 'trace_id' => $rootSpan->traceId, + 'span_id' => $rootSpan->hexId(), + 'local_root_span_id' => $rootSpan->hexId(), + 'service_name' => $rootSpan->service, + 'service_version' => $rootSpan->version, + 'deployment_environment_name' => $rootSpan->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/dropped_subspan.php b/appsec/tests/integration/src/test/www/base/public/otel_context/dropped_subspan.php new file mode 100644 index 00000000000..d7e2c80b0b8 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/dropped_subspan.php @@ -0,0 +1,33 @@ + 'missing root span']); + return; +} + +$childSpan = \DDTrace\start_span(); +if (!$childSpan) { + http_response_code(500); + echo json_encode(['error' => 'missing child span']); + return; +} + +$childSpanId = $childSpan->hexId(); +$dropped = \DDTrace\try_drop_span($childSpan); + +$waited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'waited' => $waited, + 'dropped' => $dropped, + 'trace_id' => $rootSpan->traceId, + 'span_id' => $rootSpan->hexId(), + 'child_span_id' => $childSpanId, + 'local_root_span_id' => $rootSpan->hexId(), + 'service_name' => $rootSpan->service, + 'service_version' => $rootSpan->version, + 'deployment_environment_name' => $rootSpan->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/fiber_switch.php b/appsec/tests/integration/src/test/www/base/public/otel_context/fiber_switch.php new file mode 100644 index 00000000000..bb8f2e20e1f --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/fiber_switch.php @@ -0,0 +1,53 @@ + 'missing main root span']); + return; +} + +$state = []; +$fiber = new \Fiber(function () use (&$state) { + $fiberRoot = \DDTrace\start_trace_span(); + if (!$fiberRoot) { + $state['error'] = 'missing fiber root span'; + return; + } + + file_put_contents('/tmp/otel_context_phase', 'fiber'); + $state['fiber_waited'] = \datadog\appsec\testing\wait_for_debugger(); + $state['fiber_trace_id'] = $fiberRoot->traceId; + $state['fiber_span_id'] = $fiberRoot->hexId(); + $state['fiber_local_root_span_id'] = $fiberRoot->hexId(); + + \Fiber::suspend(); + \DDTrace\close_span(); +}); + +$fiber->start(); +if (isset($state['error'])) { + http_response_code(500); + echo json_encode(['error' => $state['error']]); + return; +} + +file_put_contents('/tmp/otel_context_phase', 'main'); +$mainWaited = \datadog\appsec\testing\wait_for_debugger(); + +$fiber->resume(); + +header('Content-Type: application/json'); +echo json_encode([ + 'fiber_waited' => $state['fiber_waited'] ?? false, + 'main_waited' => $mainWaited, + 'main_trace_id' => $mainRoot->traceId, + 'main_span_id' => $mainRoot->hexId(), + 'main_local_root_span_id' => $mainRoot->hexId(), + 'fiber_trace_id' => $state['fiber_trace_id'] ?? null, + 'fiber_span_id' => $state['fiber_span_id'] ?? null, + 'fiber_local_root_span_id' => $state['fiber_local_root_span_id'] ?? null, + 'service_name' => $mainRoot->service, + 'service_version' => $mainRoot->version, + 'deployment_environment_name' => $mainRoot->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/nested_root_service_env_changes.php b/appsec/tests/integration/src/test/www/base/public/otel_context/nested_root_service_env_changes.php new file mode 100644 index 00000000000..6f673484935 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/nested_root_service_env_changes.php @@ -0,0 +1,58 @@ + 'missing entrypoint root span']); + return; +} + +$originalService = $entrypointRoot->service; +$originalVersion = $entrypointRoot->version; +$originalEnv = $entrypointRoot->env; + +$nestedRoot = \DDTrace\start_trace_span(); +if (!$nestedRoot) { + http_response_code(500); + echo json_encode(['error' => 'missing nested root span']); + return; +} + +$nestedRoot->service = 'otel-thread-context-nested-service'; +$nestedRoot->version = '7.8.9'; +$nestedRoot->env = 'otel-thread-context-nested-env'; + +$entrypointRoot->service = 'otel-thread-context-entrypoint-service'; +$entrypointRoot->version = '4.5.6'; +$entrypointRoot->env = 'otel-thread-context-entrypoint-env'; + +file_put_contents('/tmp/otel_context_phase', 'entrypoint-updated-while-nested'); +$entrypointUpdatedWaited = \datadog\appsec\testing\wait_for_debugger(); + +\DDTrace\switch_stack($entrypointRoot); +\DDTrace\switch_stack($nestedRoot); + +file_put_contents('/tmp/otel_context_phase', 'nested-restored'); +$nestedRestoredWaited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'waited' => $nestedRestoredWaited, + 'entrypoint_updated_waited' => $entrypointUpdatedWaited, + 'nested_restored_waited' => $nestedRestoredWaited, + 'trace_id' => $nestedRoot->traceId, + 'span_id' => $nestedRoot->hexId(), + 'local_root_span_id' => $nestedRoot->hexId(), + 'service_name' => $entrypointRoot->service, + 'service_version' => $entrypointRoot->version, + 'deployment_environment_name' => $entrypointRoot->env, + 'original_service_name' => $originalService, + 'original_service_version' => $originalVersion, + 'original_deployment_environment_name' => $originalEnv, + 'entrypoint_service_name' => $entrypointRoot->service, + 'entrypoint_service_version' => $entrypointRoot->version, + 'entrypoint_deployment_environment_name' => $entrypointRoot->env, + 'nested_service_name' => $nestedRoot->service, + 'nested_service_version' => $nestedRoot->version, + 'nested_deployment_environment_name' => $nestedRoot->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/reenable.php b/appsec/tests/integration/src/test/www/base/public/otel_context/reenable.php new file mode 100644 index 00000000000..bd6a3308030 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/reenable.php @@ -0,0 +1,35 @@ + 'missing initial root span']); + return; +} + +ini_set('datadog.trace.enabled', '0'); +file_put_contents('/tmp/otel_context_phase', 'disabled'); +$disabledWaited = \datadog\appsec\testing\wait_for_debugger(); + +ini_set('datadog.trace.enabled', '1'); +$rootSpan = \DDTrace\root_span(); +if (!$rootSpan) { + http_response_code(500); + echo json_encode(['error' => 'missing reenabled root span']); + return; +} + +file_put_contents('/tmp/otel_context_phase', 'reenabled'); +$reenabledWaited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'waited' => $reenabledWaited, + 'disabled_waited' => $disabledWaited, + 'trace_id' => $rootSpan->traceId, + 'span_id' => $rootSpan->hexId(), + 'local_root_span_id' => $rootSpan->hexId(), + 'service_name' => $rootSpan->service, + 'service_version' => $rootSpan->version, + 'deployment_environment_name' => $rootSpan->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/regular.php b/appsec/tests/integration/src/test/www/base/public/otel_context/regular.php new file mode 100644 index 00000000000..a741e91c374 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/regular.php @@ -0,0 +1,21 @@ + 'missing root span']); + return; +} + +$waited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'waited' => $waited, + 'trace_id' => $rootSpan->traceId, + 'span_id' => $rootSpan->hexId(), + 'local_root_span_id' => $rootSpan->hexId(), + 'service_name' => $rootSpan->service, + 'service_version' => $rootSpan->version, + 'deployment_environment_name' => $rootSpan->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/root_span_service_env_changes.php b/appsec/tests/integration/src/test/www/base/public/otel_context/root_span_service_env_changes.php new file mode 100644 index 00000000000..ec539b54969 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/root_span_service_env_changes.php @@ -0,0 +1,25 @@ + 'missing root span']); + return; +} + +$rootSpan->service = 'otel-thread-context-root-service'; +$rootSpan->version = '3.4.5'; +$rootSpan->env = 'otel-thread-context-root-env'; + +$waited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'waited' => $waited, + 'trace_id' => $rootSpan->traceId, + 'span_id' => $rootSpan->hexId(), + 'local_root_span_id' => $rootSpan->hexId(), + 'service_name' => $rootSpan->service, + 'service_version' => $rootSpan->version, + 'deployment_environment_name' => $rootSpan->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/root_trace_id_changes.php b/appsec/tests/integration/src/test/www/base/public/otel_context/root_trace_id_changes.php new file mode 100644 index 00000000000..b500858acce --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/root_trace_id_changes.php @@ -0,0 +1,25 @@ + 'missing root span']); + return; +} + +$originalTraceId = $rootSpan->traceId; +$rootSpan->traceId = '11111111111111112222222222222222'; + +$waited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'waited' => $waited, + 'original_trace_id' => $originalTraceId, + 'trace_id' => $rootSpan->traceId, + 'span_id' => $rootSpan->hexId(), + 'local_root_span_id' => $rootSpan->hexId(), + 'service_name' => $rootSpan->service, + 'service_version' => $rootSpan->version, + 'deployment_environment_name' => $rootSpan->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/runtime_service_env_changes.php b/appsec/tests/integration/src/test/www/base/public/otel_context/runtime_service_env_changes.php new file mode 100644 index 00000000000..e102d298b3c --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/runtime_service_env_changes.php @@ -0,0 +1,25 @@ + 'missing root span']); + return; +} + +ini_set('datadog.service', 'otel-thread-context-updated-service'); +ini_set('datadog.version', '2.3.4'); +ini_set('datadog.env', 'otel-thread-context-updated-env'); + +$waited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'waited' => $waited, + 'trace_id' => $rootSpan->traceId, + 'span_id' => $rootSpan->hexId(), + 'local_root_span_id' => $rootSpan->hexId(), + 'service_name' => $rootSpan->service, + 'service_version' => $rootSpan->version, + 'deployment_environment_name' => $rootSpan->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/set_distributed_tracing_context.php b/appsec/tests/integration/src/test/www/base/public/otel_context/set_distributed_tracing_context.php new file mode 100644 index 00000000000..c6c481576b8 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/set_distributed_tracing_context.php @@ -0,0 +1,25 @@ + 'missing root span']); + return; +} + +$originalTraceId = $rootSpan->traceId; +\DDTrace\set_distributed_tracing_context('22685491128062564232121423433698517538', '42'); + +$waited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'waited' => $waited, + 'original_trace_id' => $originalTraceId, + 'trace_id' => $rootSpan->traceId, + 'span_id' => $rootSpan->hexId(), + 'local_root_span_id' => $rootSpan->hexId(), + 'service_name' => $rootSpan->service, + 'service_version' => $rootSpan->version, + 'deployment_environment_name' => $rootSpan->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/stack_switches.php b/appsec/tests/integration/src/test/www/base/public/otel_context/stack_switches.php new file mode 100644 index 00000000000..fbfd37cd677 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/stack_switches.php @@ -0,0 +1,50 @@ + 'missing entrypoint root span']); + return; +} + +$entrypointStack = \DDTrace\active_stack(); + +\DDTrace\switch_stack(); +file_put_contents('/tmp/otel_context_phase', 'empty'); +$emptyWaited = \datadog\appsec\testing\wait_for_debugger(); + +\DDTrace\switch_stack($entrypointStack); +file_put_contents('/tmp/otel_context_phase', 'entrypoint-restored'); +$entrypointRestoredWaited = \datadog\appsec\testing\wait_for_debugger(); + +$nestedRoot = \DDTrace\start_trace_span(); +if (!$nestedRoot) { + http_response_code(500); + echo json_encode(['error' => 'missing nested root span']); + return; +} + +\DDTrace\switch_stack($entrypointStack); +file_put_contents('/tmp/otel_context_phase', 'entrypoint-switched'); +$entrypointSwitchedWaited = \datadog\appsec\testing\wait_for_debugger(); + +\DDTrace\switch_stack($nestedRoot); +file_put_contents('/tmp/otel_context_phase', 'nested-switched'); +$nestedSwitchedWaited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'empty_waited' => $emptyWaited, + 'entrypoint_restored_waited' => $entrypointRestoredWaited, + 'entrypoint_switched_waited' => $entrypointSwitchedWaited, + 'nested_switched_waited' => $nestedSwitchedWaited, + 'entrypoint_trace_id' => $entrypointRoot->traceId, + 'entrypoint_span_id' => $entrypointRoot->hexId(), + 'entrypoint_local_root_span_id' => $entrypointRoot->hexId(), + 'nested_trace_id' => $nestedRoot->traceId, + 'nested_span_id' => $nestedRoot->hexId(), + 'nested_local_root_span_id' => $nestedRoot->hexId(), + 'service_name' => $entrypointRoot->service, + 'service_version' => $entrypointRoot->version, + 'deployment_environment_name' => $entrypointRoot->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/subspan.php b/appsec/tests/integration/src/test/www/base/public/otel_context/subspan.php new file mode 100644 index 00000000000..ffbe5ea54a1 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/subspan.php @@ -0,0 +1,30 @@ + 'missing root span']); + return; +} + +$childSpan = \DDTrace\start_span(); +if (!$childSpan) { + http_response_code(500); + echo json_encode(['error' => 'missing child span']); + return; +} + +$waited = \datadog\appsec\testing\wait_for_debugger(); + +header('Content-Type: application/json'); +echo json_encode([ + 'waited' => $waited, + 'root_trace_id' => $rootSpan->traceId, + 'trace_id' => $rootSpan->traceId, + 'span_id' => $childSpan->hexId(), + 'child_span_id' => $childSpan->hexId(), + 'local_root_span_id' => $rootSpan->hexId(), + 'service_name' => $rootSpan->service, + 'service_version' => $rootSpan->version, + 'deployment_environment_name' => $rootSpan->env, +]); diff --git a/appsec/tests/integration/src/test/www/base/public/otel_context/user_request.php b/appsec/tests/integration/src/test/www/base/public/otel_context/user_request.php new file mode 100644 index 00000000000..bd093311772 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context/user_request.php @@ -0,0 +1,39 @@ +name = 'otel_context.user_request'; +$userRequestSpan->resource = 'otel_context.user_request'; + +\DDTrace\UserRequest\notify_start($userRequestSpan, [ + '_GET' => $_GET, + '_POST' => $_POST, + '_SERVER' => $_SERVER, + '_FILES' => $_FILES, + '_COOKIE' => $_COOKIE, +]); + +$waited = \datadog\appsec\testing\wait_for_debugger(); + +$response = [ + 'waited' => $waited, + 'trace_id' => $userRequestSpan->traceId, + 'span_id' => $userRequestSpan->hexId(), + 'local_root_span_id' => $userRequestSpan->hexId(), + 'outer_span_id' => $outerSpan ? $outerSpan->hexId() : null, + 'service_name' => $outerSpan ? $outerSpan->service : null, + 'service_version' => $outerSpan ? $outerSpan->version : null, + 'deployment_environment_name' => $outerSpan ? $outerSpan->env : null, +]; + +\DDTrace\UserRequest\notify_commit($userRequestSpan, 200, [ + 'Content-Type' => ['application/json'], +]); +\DDTrace\close_span(); + +if ($outerSpan) { + \DDTrace\switch_stack($outerSpan); +} + +header('Content-Type: application/json'); +echo json_encode($response); diff --git a/cbindgen.toml b/cbindgen.toml index 2d4ec6b3588..3916e94a9cd 100644 --- a/cbindgen.toml +++ b/cbindgen.toml @@ -11,6 +11,9 @@ no_includes = true sys_includes = ["stdbool.h", "stddef.h", "stdint.h"] includes = ["common.h", "telemetry.h", "sidecar.h"] +[defines] +"target_os = linux" = "__linux__" + [export] prefix = "ddog_" renaming_overrides_prefixing = true diff --git a/components-rs/Cargo.toml b/components-rs/Cargo.toml index c4b8de1c341..a1e0cf8d36d 100644 --- a/components-rs/Cargo.toml +++ b/components-rs/Cargo.toml @@ -23,7 +23,9 @@ libdd-data-pipeline = { path = "../libdatadog/libdd-data-pipeline" } libdd-tinybytes = { path = "../libdatadog/libdd-tinybytes" } libdd-trace-utils = { path = "../libdatadog/libdd-trace-utils" } libdd-trace-stats = { path = "../libdatadog/libdd-trace-stats" } +libdd-trace-protobuf = { path = "../libdatadog/libdd-trace-protobuf" } libdd-crashtracker-ffi = { path = "../libdatadog/libdd-crashtracker-ffi", default-features = false, features = ["collector"] } +libdd-library-config = { path = "../libdatadog/libdd-library-config", features = ["otel-thread-ctx"] } libdd-library-config-ffi = { path = "../libdatadog/libdd-library-config-ffi", default-features = false } spawn_worker = { path = "../libdatadog/spawn_worker" } anyhow = { version = "1.0" } @@ -53,7 +55,6 @@ http = "1.0" libc = "0.2" bincode = { version = "1.3.3" } hashbrown = "0.15" - [build-dependencies] cbindgen = "0.27" diff --git a/components-rs/common.h b/components-rs/common.h index 9a148cb5d8b..e19e8d0d59e 100644 --- a/components-rs/common.h +++ b/components-rs/common.h @@ -412,8 +412,11 @@ typedef enum ddog_RemoteConfigCapabilities { DDOG_REMOTE_CONFIG_CAPABILITIES_APM_TRACING_ENABLE_LIVE_DEBUGGING = 41, DDOG_REMOTE_CONFIG_CAPABILITIES_ASM_DD_MULTICONFIG = 42, DDOG_REMOTE_CONFIG_CAPABILITIES_ASM_TRACE_TAGGING_RULES = 43, + DDOG_REMOTE_CONFIG_CAPABILITIES_ASM_EXTENDED_DATA_COLLECTION = 44, DDOG_REMOTE_CONFIG_CAPABILITIES_APM_TRACING_MULTICONFIG = 45, DDOG_REMOTE_CONFIG_CAPABILITIES_FFE_FLAG_CONFIGURATION_RULES = 46, + DDOG_REMOTE_CONFIG_CAPABILITIES_DD_DATA_STREAMS_TRANSACTION_EXTRACTORS = 47, + DDOG_REMOTE_CONFIG_CAPABILITIES_LLM_OBS_ACTIVATION = 48, } ddog_RemoteConfigCapabilities; typedef enum ddog_RemoteConfigProduct { @@ -426,6 +429,7 @@ typedef enum ddog_RemoteConfigProduct { 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_RemoteConfigProduct; typedef enum ddog_SpanProbeTarget { @@ -478,6 +482,8 @@ typedef struct ddog_SidecarTransport ddog_SidecarTransport; */ typedef struct ddog_SpanConcentrator ddog_SpanConcentrator; +typedef struct _zend_string *ddog_OwnedZendString; + typedef struct ddog_FfeResult { _zend_string * value_json; _zend_string * variant; @@ -524,8 +530,6 @@ typedef struct ddog_Tag { const struct ddog_DslString *value; } ddog_Tag; -typedef struct _zend_string *ddog_OwnedZendString; - typedef struct _zend_string *(*ddog_DynamicConfigUpdate)(ddog_CharSlice config, ddog_OwnedZendString value, enum ddog_DynamicConfigUpdateMode mode); @@ -1981,6 +1985,79 @@ typedef struct ddog_Result_TracerMemfdHandle { }; } ddog_Result_TracerMemfdHandle; +/** + * Maximum size in bytes of the `attrs_data` field of a thread context record. + */ +#define ddog_MAX_ATTRS_DATA_SIZE 612 +/** + * Opaque handle to an owned thread context record. Used to allow the FFI to convert + * [ThreadContext] to and from raw pointers without exposing Rust ownership details. + * + * This is intentionally not `repr(C)`: C only ever sees pointers to this token, and cbindgen + * emits it as an opaque forward declaration. The public cross-process layout is + * `ThreadContextRecord`, not this ownership handle. + */ +typedef struct ddog_ThreadContextHandle ddog_ThreadContextHandle; +typedef struct ddog_OtelThreadContextAttribute { + uint8_t key_index; + ddog_CharSlice value; +} ddog_OtelThreadContextAttribute; +/** + * In-memory layout of a thread-level context. + * + * **CAUTION**: The structure MUST match exactly the OTel thread-level context specification. + * It is read by external, out-of-process code. Do not re-order fields or modify in any way, + * unless you know exactly what you're doing. + * + * # Synchronization + * + * Readers are async-signal handlers. The writer is always stopped while a reader runs. + * Sharing memory with a signal handler still requires some form of synchronization, which is + * achieved through atomics and compiler fence, using `valid` and/or the TLS slot as + * synchronization points. + * + * - The writer stores `valid = 0` *before* modifying fields in-place, guarded by a fence. + * - The writer stores `valid = 1` *after* all fields are populated, guarded by a fence. + * - `valid` starts at `1` on construction and is never set to `0` except during an in-place + * update. + */ +typedef struct ddog_ThreadContextRecord { + /** + * Trace identifier; all-zeroes means "no trace". + */ + uint8_t trace_id[16]; + /** + * Span identifier, stored with the exact byte representation provided by the caller. + */ + uint8_t span_id[8]; + /** + * Whether the record is ready/consistent. Always set to `1` except during in-place update + * of the current record. + */ + uint8_t valid; + uint8_t _reserved; + /** + * Number of populated bytes in `attrs_data`. + */ + uint16_t attrs_data_size; + /** + * Packed variable-length key-value records. + * + * It's a contiguous list of blocks with layout: + * + * 1. 1-byte `key_index` + * 2. 1-byte `val_len` + * 3. `val_len` bytes of a string value. + * + * # Size + * + * Currently, we always allocate the max recommended size. This potentially wastes a few + * hundred bytes per thread, but it guarantees that we can modify the context in-place + * without (re)allocation in the hot path. Having a hybrid scheme (starting smaller and + * resizing up a few times) is not out of the question. + */ + uint8_t attrs_data[ddog_MAX_ATTRS_DATA_SIZE]; +} ddog_ThreadContextRecord; #ifdef __cplusplus extern "C" { #endif // __cplusplus diff --git a/components-rs/datadog.h b/components-rs/datadog.h index bb08a554b10..50dada48c32 100644 --- a/components-rs/datadog.h +++ b/components-rs/datadog.h @@ -41,6 +41,30 @@ void datadog_generate_session_id(void); void datadog_format_runtime_id(uint8_t (*buf)[36]); +#if defined(__linux__) +bool datadog_publish_otel_process_context(ddog_CharSlice hostname); +#endif + +#if !defined(__linux__) +bool datadog_publish_otel_process_context(ddog_CharSlice _hostname); +#endif + +/** + * Writes the base pointer and length of the currently-published OTel process context mapping into + * `base_out`/`len_out` and returns `true`; returns `false` (leaving the out-params untouched) if + * no context is published or the publisher has forked and not yet republished. + * + * # Safety + * `base_out` and `len_out` must be valid, non-null, writable pointers. + */ +#if defined(__linux__) +bool datadog_otel_process_context_mapping(const uint8_t **base_out, uintptr_t *len_out); +#endif + +#if !defined(__linux__) +bool datadog_otel_process_context_mapping(const uint8_t **_base_out, uintptr_t *_len_out); +#endif + ddog_CharSlice ddtrace_get_container_id(void); void ddtrace_set_container_cgroup_path(ddog_CharSlice path); @@ -61,7 +85,9 @@ void datadog_endpoint_as_crashtracker_config(const struct ddog_Endpoint *endpoin ddog_Configurator *ddog_library_configurator_new_dummy(bool debug_logs, ddog_CharSlice language); +#if defined(__linux__) int posix_spawn_file_actions_addchdir_np(void *file_actions, const char *path); +#endif uint64_t dd_fnv1a_64(const uint8_t *data, uintptr_t len); @@ -110,6 +136,64 @@ void ddog_agent_info_json_free(char *ptr); */ void ddog_apply_agent_info_concentrator_config(struct ddog_AgentInfoReader *reader); +void ddog_init_span_func(void (*free_func)(ddog_OwnedZendString), + void (*addref_func)(struct _zend_string*), + ddog_OwnedZendString (*init_func)(ddog_CharSlice)); + +void ddog_set_span_service_zstr(ddog_SpanBytes *ptr, struct _zend_string *str); + +void ddog_set_span_name_zstr(ddog_SpanBytes *ptr, struct _zend_string *str); + +void ddog_set_span_resource_zstr(ddog_SpanBytes *ptr, struct _zend_string *str); + +void ddog_set_span_type_zstr(ddog_SpanBytes *ptr, struct _zend_string *str); + +void ddog_add_span_meta_zstr(ddog_SpanBytes *ptr, + struct _zend_string *key, + struct _zend_string *val); + +void ddog_add_CharSlice_span_meta_zstr(ddog_SpanBytes *ptr, + ddog_CharSlice key, + struct _zend_string *val); + +void ddog_add_zstr_span_meta_str(ddog_SpanBytes *ptr, struct _zend_string *key, const char *val); + +void ddog_add_str_span_meta_str(ddog_SpanBytes *ptr, const char *key, const char *val); + +void ddog_add_str_span_meta_zstr(ddog_SpanBytes *ptr, const char *key, struct _zend_string *val); + +void ddog_add_str_span_meta_CharSlice(ddog_SpanBytes *ptr, const char *key, ddog_CharSlice val); + +void ddog_del_span_meta_zstr(ddog_SpanBytes *ptr, struct _zend_string *key); + +void ddog_del_span_meta_str(ddog_SpanBytes *ptr, const char *key); + +bool ddog_has_span_meta_zstr(ddog_SpanBytes *ptr, struct _zend_string *key); + +bool ddog_has_span_meta_str(ddog_SpanBytes *ptr, const char *key); + +ddog_CharSlice ddog_get_span_meta_str(ddog_SpanBytes *span, const char *key); + +void ddog_add_span_metrics_zstr(ddog_SpanBytes *ptr, struct _zend_string *key, double val); + +bool ddog_has_span_metrics_zstr(ddog_SpanBytes *ptr, struct _zend_string *key); + +void ddog_del_span_metrics_zstr(ddog_SpanBytes *ptr, struct _zend_string *key); + +void ddog_add_span_metrics_str(ddog_SpanBytes *ptr, const char *key, double val); + +bool ddog_get_span_metrics_str(ddog_SpanBytes *ptr, const char *key, double *result); + +void ddog_del_span_metrics_str(ddog_SpanBytes *ptr, const char *key); + +void ddog_add_span_meta_struct_zstr(ddog_SpanBytes *ptr, + struct _zend_string *key, + struct _zend_string *val); + +void ddog_add_zstr_span_meta_struct_CharSlice(ddog_SpanBytes *ptr, + struct _zend_string *key, + ddog_CharSlice val); + bool ddog_ffe_load_config(ddog_CharSlice json); bool ddog_ffe_has_config(void); @@ -415,62 +499,4 @@ bool ddog_check_stats_trace_filter(ddog_CharSlice resource, const void *root_span, ddog_RootTagLookupFn lookup_fn); -void ddog_init_span_func(void (*free_func)(ddog_OwnedZendString), - void (*addref_func)(struct _zend_string*), - ddog_OwnedZendString (*init_func)(ddog_CharSlice)); - -void ddog_set_span_service_zstr(ddog_SpanBytes *ptr, struct _zend_string *str); - -void ddog_set_span_name_zstr(ddog_SpanBytes *ptr, struct _zend_string *str); - -void ddog_set_span_resource_zstr(ddog_SpanBytes *ptr, struct _zend_string *str); - -void ddog_set_span_type_zstr(ddog_SpanBytes *ptr, struct _zend_string *str); - -void ddog_add_span_meta_zstr(ddog_SpanBytes *ptr, - struct _zend_string *key, - struct _zend_string *val); - -void ddog_add_CharSlice_span_meta_zstr(ddog_SpanBytes *ptr, - ddog_CharSlice key, - struct _zend_string *val); - -void ddog_add_zstr_span_meta_str(ddog_SpanBytes *ptr, struct _zend_string *key, const char *val); - -void ddog_add_str_span_meta_str(ddog_SpanBytes *ptr, const char *key, const char *val); - -void ddog_add_str_span_meta_zstr(ddog_SpanBytes *ptr, const char *key, struct _zend_string *val); - -void ddog_add_str_span_meta_CharSlice(ddog_SpanBytes *ptr, const char *key, ddog_CharSlice val); - -void ddog_del_span_meta_zstr(ddog_SpanBytes *ptr, struct _zend_string *key); - -void ddog_del_span_meta_str(ddog_SpanBytes *ptr, const char *key); - -bool ddog_has_span_meta_zstr(ddog_SpanBytes *ptr, struct _zend_string *key); - -bool ddog_has_span_meta_str(ddog_SpanBytes *ptr, const char *key); - -ddog_CharSlice ddog_get_span_meta_str(ddog_SpanBytes *span, const char *key); - -void ddog_add_span_metrics_zstr(ddog_SpanBytes *ptr, struct _zend_string *key, double val); - -bool ddog_has_span_metrics_zstr(ddog_SpanBytes *ptr, struct _zend_string *key); - -void ddog_del_span_metrics_zstr(ddog_SpanBytes *ptr, struct _zend_string *key); - -void ddog_add_span_metrics_str(ddog_SpanBytes *ptr, const char *key, double val); - -bool ddog_get_span_metrics_str(ddog_SpanBytes *ptr, const char *key, double *result); - -void ddog_del_span_metrics_str(ddog_SpanBytes *ptr, const char *key); - -void ddog_add_span_meta_struct_zstr(ddog_SpanBytes *ptr, - struct _zend_string *key, - struct _zend_string *val); - -void ddog_add_zstr_span_meta_struct_CharSlice(ddog_SpanBytes *ptr, - struct _zend_string *key, - ddog_CharSlice val); - #endif /* DDTRACE_PHP_H */ diff --git a/components-rs/lib.rs b/components-rs/lib.rs index f73beec72d2..7d5314cc6b7 100644 --- a/components-rs/lib.rs +++ b/components-rs/lib.rs @@ -13,25 +13,45 @@ pub mod telemetry; pub mod trace_filter; pub mod bytes; -use libdd_common::entity_id::{get_container_id, set_cgroup_file}; +pub use datadog_sidecar_ffi::*; +pub use libdd_crashtracker_ffi::*; +pub use libdd_common_ffi::*; +pub use libdd_library_config_ffi::*; +pub use libdd_telemetry_ffi::*; + use http::uri::{PathAndQuery, Scheme}; use http::Uri; +use libdd_common::entity_id::{get_container_id, set_cgroup_file}; +use libdd_common::{parse_uri, Endpoint}; +use libdd_common_ffi::slice::{AsBytes, CharSlice}; use std::borrow::Cow; use std::ffi::{c_char, OsStr}; -#[cfg(unix)] -use std::path::Path; use std::ptr::null_mut; use uuid::Uuid; -pub use libdd_crashtracker_ffi::*; -pub use libdd_library_config_ffi::*; -pub use datadog_sidecar_ffi::*; -use libdd_common::{parse_uri, Endpoint}; #[cfg(unix)] use libdd_common::connector::uds::socket_path_to_uri; -use libdd_common_ffi::slice::AsBytes; -pub use libdd_common_ffi::*; -pub use libdd_telemetry_ffi::*; +#[cfg(unix)] +use std::path::Path; + +#[cfg(target_os = "linux")] +use libdd_library_config::otel_process_ctx::{self, ProcessContextHandle}; +#[cfg(target_os = "linux")] +use libdd_library_config::tracer_metadata::{ThreadLocalMetadata, TracerMetadata}; +#[cfg(target_os = "linux")] +use std::sync::Mutex; + +/// Process-wide owner of the published OTel process context. Holding the handle keeps the mapping +/// alive for the process lifetime; the mapping base/length are cached per-thread in the `datadog` +/// module globals for readers (see [`datadog_otel_process_context_mapping`]). libdatadog no longer +/// keeps this state itself — the runtime owns "where the mapping is" and passes it back to +/// [`otel_process_ctx::read`]. +// NOTE: `Option` is fully qualified because this crate glob-imports `libdd_common_ffi::*`, which +// shadows the prelude `Option` type (its `Some`/`None` variants are not glob-imported, so those +// still resolve to the prelude). +#[cfg(target_os = "linux")] +static OTEL_PROCESS_CTX_HANDLE: Mutex> = + Mutex::new(None); #[no_mangle] #[allow(non_upper_case_globals)] @@ -92,6 +112,106 @@ 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")] +#[no_mangle] +pub extern "C" fn datadog_publish_otel_process_context(hostname: CharSlice<'_>) -> bool { + let runtime_id = unsafe { + (!datadog_runtime_id.is_nil()).then(|| datadog_runtime_id.as_hyphenated().to_string()) + }; + + // This process context publishes the PHP-provided thread-local attribute + // key map. libdatadog prepends datadog.local_root_span_id automatically; + // request/runtime values for all keys are stored in the thread-local + // context record itself. + let metadata = TracerMetadata { + runtime_id, + hostname: hostname.to_utf8_lossy().into_owned(), + tracer_language: "php".to_owned(), + tracer_version: include_str!("../VERSION").trim().to_owned(), + threadlocal_metadata: Some(ThreadLocalMetadata { + attribute_keys: vec![ + "service.name".to_owned(), + "service.version".to_owned(), + "deployment.environment.name".to_owned(), + ], + ..Default::default() + }), + ..Default::default() + }; + + let context = metadata.to_otel_process_ctx(); + + // Publish (first call) or update (subsequent calls / after fork) and retain the owning handle + // at process scope so the mapping outlives requests and threads. `update` transparently + // republishes into a fresh mapping if it detects a fork. + let mut guard = OTEL_PROCESS_CTX_HANDLE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let result = match guard.as_mut() { + Some(handle) => handle.update(&context), + None => otel_process_ctx::publish(&context).map(|handle| { + *guard = Some(handle); + }), + }; + + match result { + Ok(()) => true, + Err(error) => { + tracing::debug!("failed to publish OTel process context: {error}"); + false + } + } +} + +#[cfg(not(target_os = "linux"))] +#[no_mangle] +pub extern "C" fn datadog_publish_otel_process_context(_hostname: CharSlice<'_>) -> bool { + false +} + +/// Writes the base pointer and length of the currently-published OTel process context mapping into +/// `base_out`/`len_out`, for the caller to cache (e.g. in the `datadog` module globals) and pass to +/// [`otel_process_ctx::read`]. Returns `false` (leaving the out-params untouched) if no context is +/// currently published. +/// +/// # Safety +/// `base_out` and `len_out` must be valid, non-null, writable pointers. +#[cfg(target_os = "linux")] +#[no_mangle] +pub unsafe extern "C" fn datadog_otel_process_context_mapping( + base_out: *mut *const u8, + len_out: *mut usize, +) -> bool { + if base_out.is_null() || len_out.is_null() { + return false; + } + let guard = OTEL_PROCESS_CTX_HANDLE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + // `current_mapping` returns None for a stale post-fork handle whose mapping is MADV_DONTFORK'd, + // so a reader never dereferences a base that isn't mapped in this (child) process. + match guard.as_ref().and_then(|handle| handle.current_mapping()) { + Some((base, len)) => { + // SAFETY: caller guarantees the out-params are valid and writable. + unsafe { + *base_out = base; + *len_out = len; + } + true + } + None => false, + } +} + +#[cfg(not(target_os = "linux"))] +#[no_mangle] +pub unsafe extern "C" fn datadog_otel_process_context_mapping( + _base_out: *mut *const u8, + _len_out: *mut usize, +) -> bool { + false +} + #[must_use] #[no_mangle] pub extern "C" fn ddtrace_get_container_id() -> CharSlice<'static> { diff --git a/components-rs/stats.rs b/components-rs/stats.rs index e2e831c48e9..9cd218bf056 100644 --- a/components-rs/stats.rs +++ b/components-rs/stats.rs @@ -11,6 +11,7 @@ use crate::trace_filter; use datadog_ipc::shm_stats::{OwnedShmSpanInput, ShmSpanConcentrator, ShmSpanInput, MAX_PEER_TAGS}; use datadog_sidecar::service::blocking::{add_span_to_concentrator, SidecarTransport}; use libdd_trace_stats::span_concentrator::FixedAggregationKey; +use libdd_trace_protobuf::pb; use libdd_common_ffi::slice::{AsBytes, CharSlice}; use std::collections::HashMap; use std::ffi::{c_char, c_void}; @@ -148,7 +149,11 @@ fn build_fixed_key<'a>(span: &'a PhpSpanStats<'a>) -> FixedAggregationKey<&'a st http_endpoint: extract_http_endpoint(span), http_status_code: extract_http_status_code(span), is_synthetics_request: is_synthetics_request(span), - is_trace_root: span.is_trace_root, + is_trace_root: if span.is_trace_root { + pb::Trilean::True + } else { + pb::Trilean::False + }, grpc_status_code: extract_grpc_status_code(span), service_source: char_slice_str(span.service_source), } @@ -521,4 +526,3 @@ pub unsafe extern "C" fn ddog_sidecar_add_php_span_to_concentrator( trace!("Failed to send span to concentrator via IPC: {e}"); } } - diff --git a/config.m4 b/config.m4 index aa69d309a43..7619a34332d 100644 --- a/config.m4 +++ b/config.m4 @@ -224,6 +224,7 @@ if test "$PHP_DDTRACE" != "no"; then tracer/live_debugger.c \ tracer/limiter/limiter.c \ tracer/memory_limit.c \ + tracer/otel_context.c \ tracer/tracer_otel_config.c \ tracer/priority_sampling/priority_sampling.c \ tracer/profiling.c \ @@ -309,8 +310,23 @@ if test "$PHP_DDTRACE" != "no"; then if test "$ext_shared" = "yes"; then dnl Only export symbols defined in datadog.sym, which should all be marked as dnl DATADOG_PUBLIC in their source files as well. + DDTRACE_EXPORT_SYMBOLS="$ext_srcdir/datadog.sym" + case $host_os in + linux*) + AX_CHECK_COMPILE_FLAG([-mtls-dialect=gnu2], + [EXTRA_CFLAGS="$EXTRA_CFLAGS -mtls-dialect=gnu2"], + [], + [], + []) + ;; + *) + dnl otel_thread_ctx_v1 is a Linux-only TLS symbol. + DDTRACE_EXPORT_SYMBOLS="$ext_builddir/datadog.sym" + $GREP -v '^otel_thread_ctx_v1$' "$ext_srcdir/datadog.sym" > "$DDTRACE_EXPORT_SYMBOLS" + ;; + esac EXTRA_CFLAGS="$EXTRA_CFLAGS -fvisibility=hidden" - EXTRA_LDFLAGS="$EXTRA_LDFLAGS -export-symbols $ext_srcdir/datadog.sym -flto -fuse-linker-plugin" + 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/config.w32 b/config.w32 index 5ff5b2e9d70..860834f86ab 100644 --- a/config.w32 +++ b/config.w32 @@ -66,6 +66,7 @@ if (PHP_DDTRACE != 'no') { DDTRACE_TRACER_SOURCES += " ip_extraction.c"; DDTRACE_TRACER_SOURCES += " live_debugger.c"; DDTRACE_TRACER_SOURCES += " memory_limit.c"; + DDTRACE_TRACER_SOURCES += " otel_context.c"; DDTRACE_TRACER_SOURCES += " tracer_otel_config.c"; DDTRACE_TRACER_SOURCES += " profiling.c"; DDTRACE_TRACER_SOURCES += " random.c"; @@ -199,6 +200,7 @@ if (PHP_DDTRACE != 'no') { deffile.WriteLine("EXPORTS"); var contents = FSO.OpenTextFile(configure_module_dirname + "/datadog.sym", 1).ReadAll(); contents = contents.replace(/ddog_crashtracker_entry_point\s*/, ""); // unix-only symbol + contents = contents.replace(/otel_thread_ctx_v1\s*/, ""); // linux-only TLS variable contents = contents + "\n" + FSO.OpenTextFile(configure_module_dirname + "/datadog-windows.sym", 1).ReadAll(); if (!PHP_DDTRACE_SHARED) { contents = contents.replace(/get_module\s*/, ""); diff --git a/datadog.sym b/datadog.sym index 510519066f2..29833ce9fac 100644 --- a/datadog.sym +++ b/datadog.sym @@ -1,6 +1,8 @@ ddtrace_close_all_spans_and_flush datadog_get_formatted_session_id ddtrace_get_profiling_context +ddtrace_get_otel_process_ctx_mapping +otel_thread_ctx_v1 ddtrace_get_root_span datadog_process_tags_get_serialized datadog_get_sidecar_queue_id diff --git a/ext/datadog.c b/ext/datadog.c index fc2cd4622c4..4f35bb5c13f 100644 --- a/ext/datadog.c +++ b/ext/datadog.c @@ -10,6 +10,7 @@ #include "configuration.h" #include "excluded_modules.h" #include "agent_info.h" +#include "ffi_utils.h" #include "logging.h" #include "phpinfo.h" #include "process_tags.h" @@ -21,11 +22,13 @@ #include "zend_hrtime.h" #ifndef _WIN32 #include +#include #else #include #include "crashtracking_windows.h" #endif #include +#include #if PHP_VERSION_ID < 80000 #include #endif @@ -152,11 +155,14 @@ static void datadog_shutdown(zend_extension *extension) { #endif } +static void datadog_publish_configured_otel_process_context(void); + static void dd_activate_once(void) { datadog_config_first_rinit(); if (dd_main_pid != getpid()) { // equal to session id if not a fork datadog_generate_runtime_id(); } + datadog_publish_configured_otel_process_context(); // must run before the first zai_hook_activate as tracer telemetry setup installs a global hook if (!datadog_disable) { @@ -172,6 +178,35 @@ static void dd_activate_once(void) { } } +static void datadog_publish_configured_otel_process_context(void) { + ddog_CharSlice hostname = DDOG_CHARSLICE_C(""); + + if (!get_DD_TRACE_REPORT_HOSTNAME()) { + datadog_publish_otel_process_context(hostname); + return; + } + + if (ZSTR_LEN(get_DD_HOSTNAME())) { + hostname = dd_zend_string_to_CharSlice(get_DD_HOSTNAME()); + datadog_publish_otel_process_context(hostname); + return; + } + + // Match tracer/serializer.c hostname publishing: DD_HOSTNAME wins, then gethostname(). +#ifndef HOST_NAME_MAX +#define HOST_NAME_MAX 255 +#endif + char hostname_buf[HOST_NAME_MAX + 1]; + if (gethostname(hostname_buf, sizeof(hostname_buf)) == 0) { + hostname_buf[HOST_NAME_MAX] = '\0'; + hostname = (ddog_CharSlice){.ptr = hostname_buf, .len = strlen(hostname_buf)}; + datadog_publish_otel_process_context(hostname); + return; + } + + datadog_publish_otel_process_context(hostname); +} + static pthread_once_t dd_activate_once_control = PTHREAD_ONCE_INIT; static bool dd_is_cli_autodisabled(const char *arg) { @@ -704,7 +739,11 @@ static PHP_MINFO_FUNCTION(datadog) { void datadog_internal_handle_fork(void) { // CHILD PROCESS - datadog_sidecar_handle_fork(); + bool runtime_id_changed = false; + datadog_sidecar_handle_fork(&runtime_id_changed); + if (runtime_id_changed) { + datadog_publish_configured_otel_process_context(); + } #ifdef DDTRACE ddtrace_internal_handle_fork(); diff --git a/ext/sidecar.c b/ext/sidecar.c index 88df83479a9..4e545934df8 100644 --- a/ext/sidecar.c +++ b/ext/sidecar.c @@ -460,8 +460,10 @@ void datadog_sidecar_minit(void) { } } -void datadog_sidecar_handle_fork(void) { +void datadog_sidecar_handle_fork(bool *runtime_id_changed) { #ifndef _WIN32 + *runtime_id_changed = false; + ddog_RemoteConfigFlags flags = {0}; bool enable_sidecar = datadog_sidecar_should_enable(&flags); @@ -469,6 +471,7 @@ void datadog_sidecar_handle_fork(void) { return; } + *runtime_id_changed = datadog_sidecar_instance_id != NULL; datadog_force_new_instance_id(); // After fork only one thread (the one that called fork) survives, so we only @@ -518,6 +521,8 @@ void datadog_sidecar_handle_fork(void) { if (DATADOG_G(sidecar)) { datadog_sidecar_for_signal = DATADOG_G(sidecar); } +#else + *runtime_id_changed = false; #endif } diff --git a/ext/sidecar.h b/ext/sidecar.h index ba5eb1d6601..1e80abc1738 100644 --- a/ext/sidecar.h +++ b/ext/sidecar.h @@ -43,7 +43,7 @@ ddog_SidecarTransport *datadog_sidecar_connect(bool is_fork); // Lifecycle functions void datadog_sidecar_minit(void); void datadog_sidecar_setup(ddog_RemoteConfigFlags flags); -void datadog_sidecar_handle_fork(void); +void datadog_sidecar_handle_fork(bool *runtime_id_changed); bool datadog_sidecar_should_enable(ddog_RemoteConfigFlags *flags); void datadog_sidecar_ensure_active(void); void datadog_sidecar_update_process_tags(void); diff --git a/libdatadog b/libdatadog index 6a6d4a535e9..99e3aaa16c5 160000 --- a/libdatadog +++ b/libdatadog @@ -1 +1 @@ -Subproject commit 6a6d4a535e9875a7b012ce3f00eb6929649c3fb5 +Subproject commit 99e3aaa16c576d8996f639b15c72f8c1670f2f4e diff --git a/profiling/Cargo.toml b/profiling/Cargo.toml index e202fbce483..9d269cd9fef 100644 --- a/profiling/Cargo.toml +++ b/profiling/Cargo.toml @@ -28,6 +28,7 @@ http = { version = "1.4" } libdd-alloc = { path = "../libdatadog/libdd-alloc" } libdd-profiling = { path = "../libdatadog/libdd-profiling" } libdd-common = { path = "../libdatadog/libdd-common" } +libdd-library-config = { path = "../libdatadog/libdd-library-config" } libdd-library-config-ffi = { path = "../libdatadog/libdd-library-config-ffi" } env_logger = { version = "0.11", default-features = false } libc = "0.2" diff --git a/profiling/build.rs b/profiling/build.rs index 59ce4cf2a58..c1b66850a2a 100644 --- a/profiling/build.rs +++ b/profiling/build.rs @@ -275,6 +275,9 @@ fn generate_bindings(php_config_includes: &str, fibers: bool, zend_error_observe .raw_line("pub type zend_vm_opcode_handler_func_t = *const ::std::ffi::c_void;") // Block a few of functions that we'll provide defs for manually .blocklist_item("datadog_php_profiling_vm_interrupt_addr") + .blocklist_item("ddog_php_prof_otel_thread_ctx_ginit") + .blocklist_item("ddog_php_prof_otel_thread_ctx_rinit") + .blocklist_item("datadog_php_profiling_context_api_name") // I had to block these for some reason *shrug* .blocklist_item("FP_INFINITE") .blocklist_item("FP_INT_DOWNWARD") diff --git a/profiling/src/bindings/mod.rs b/profiling/src/bindings/mod.rs index 220be2cd123..d679977afed 100644 --- a/profiling/src/bindings/mod.rs +++ b/profiling/src/bindings/mod.rs @@ -340,6 +340,21 @@ extern "C" { /// Must be called from a PHP thread during a request. pub fn datadog_php_profiling_vm_interrupt_addr() -> *const AtomicBool; + /// Initializes per-thread profiler FFI state. + /// # Safety + /// Must be called from a PHP thread during GINIT. + #[cfg(target_os = "linux")] + pub fn ddog_php_prof_otel_thread_ctx_ginit(); + + /// Verifies per-thread profiler FFI state. + /// # Safety + /// Must be called from a PHP thread during a request. + #[cfg(target_os = "linux")] + pub fn ddog_php_prof_otel_thread_ctx_rinit() -> bool; + + /// Returns the profiling context API selected for this request. + pub fn datadog_php_profiling_context_api_name() -> ZaiStr<'static>; + /// Registers the extension. Note that it's kept in a zend_llist and gets /// pemalloc'd + memcpy'd into place. The engine says this is a mutable /// pointer, but in practice it's const. diff --git a/profiling/src/config.rs b/profiling/src/config.rs index db8eac79de1..a590da849a1 100644 --- a/profiling/src/config.rs +++ b/profiling/src/config.rs @@ -14,6 +14,7 @@ use core::ptr; use core::str::FromStr; pub use http::Uri; use libc::{c_char, c_int}; +use libdd_common::parse_uri; use libdd_common::tag::{parse_tags, Tag}; use log::{debug, error, warn, LevelFilter}; use std::borrow::Cow; @@ -297,7 +298,7 @@ fn detect_uri_from_config( ); } } else { - match Uri::from_str(trace_agent_url.as_ref()) { + match parse_uri(trace_agent_url.as_ref()) { Ok(uri) => return AgentEndpoint::Uri(uri), Err(err) => warn!("DD_TRACE_AGENT_URL was not a valid URL: {err}"), } @@ -1483,6 +1484,15 @@ mod tests { let expected = AgentEndpoint::Uri(Uri::from_static("http://[::1]:8126")); assert_eq!(endpoint, expected); + // file dump endpoint + let endpoint = detect_uri_from_config( + Some(Cow::Owned("file:///tmp/profile-http.bin".to_owned())), + None, + None, + ); + let expected = AgentEndpoint::Uri(parse_uri("file:///tmp/profile-http.bin").unwrap()); + assert_eq!(endpoint, expected); + // fallback on non existing UDS let endpoint = detect_uri_from_config( Some(Cow::Owned("unix://foo/bar/baz/I/do/not/exist".to_owned())), diff --git a/profiling/src/lib.rs b/profiling/src/lib.rs index 4afd0e8e816..e3eba2ef667 100644 --- a/profiling/src/lib.rs +++ b/profiling/src/lib.rs @@ -580,6 +580,15 @@ extern "C" fn rinit(_type: c_int, _module_number: c_int) -> ZendResult { // values to the ones in the configuration. let system_settings = SystemSettings::get(); + #[cfg(target_os = "linux")] + { + // SAFETY: we are in rinit on a PHP thread. + if !unsafe { zend::ddog_php_prof_otel_thread_ctx_rinit() } { + error!("failed to initialize profiler OTel thread context state"); + return ZendResult::Failure; + } + } + // initialize the thread local storage and cache some items let result = REQUEST_LOCALS.try_with_borrow_mut(|locals| { // SAFETY: we are in rinit on a PHP thread. @@ -663,6 +672,13 @@ extern "C" fn rinit(_type: c_int, _module_number: c_int) -> ZendResult { let once = unsafe { &*ptr::addr_of!(RINIT_ONCE) }; once.call_once(|| { if system_settings.profiling_enabled { + // SAFETY: this returns a view of a static string owned by php_ffi.c. + let context_api = unsafe { bindings::datadog_php_profiling_context_api_name() }; + info!( + "Profiling context API selected: {}.", + context_api.to_string_lossy() + ); + // SAFETY: sapi_module is initialized by rinit and shouldn't be // modified at this point (safe to read values). let sapi_module = unsafe { &*ptr::addr_of!(zend::sapi_module) }; diff --git a/profiling/src/module_globals.rs b/profiling/src/module_globals.rs index 85c938e5355..733d700924c 100644 --- a/profiling/src/module_globals.rs +++ b/profiling/src/module_globals.rs @@ -85,6 +85,12 @@ pub unsafe extern "C" fn ginit(_globals_ptr: *mut c_void) { #[cfg(php_zts)] crate::timeline::timeline_ginit(); + #[cfg(target_os = "linux")] + { + // SAFETY: this is called by PHP's module globals ctor for the current PHP thread. + unsafe { crate::bindings::ddog_php_prof_otel_thread_ctx_ginit() }; + } + // Initialize ZendMMState in PHP globals for ZTS builds. For NTS builds, // this was already done in its const initializer. #[cfg(php_zts)] diff --git a/profiling/src/php_ffi.c b/profiling/src/php_ffi.c index 1e906f3cbd3..c946855c359 100644 --- a/profiling/src/php_ffi.c +++ b/profiling/src/php_ffi.c @@ -1,20 +1,161 @@ #include "php_ffi.h" -#include #include #include #include #include #include "SAPI.h" -#if CFG_STACK_WALKING_TESTS +#if CFG_STACK_WALKING_TESTS || defined(__linux__) #include // for dlsym #endif +#ifdef __linux__ +#include +#include +#endif const char *datadog_extension_build_id(void) { return ZEND_EXTENSION_BUILD_ID; } const char *datadog_module_build_id(void) { return ZEND_MODULE_BUILD_ID; } uint8_t *datadog_runtime_id = NULL; +static const zai_str datadog_php_profiling_context_api_none = ZAI_STRL("none"); +#ifdef __linux__ +static const zai_str ddog_php_prof_context_api_otel = ZAI_STRL("otel_thread_ctx_v1"); +#endif +static const zai_str datadog_php_profiling_context_api_legacy = ZAI_STRL("ddtrace_get_profiling_context"); + +static ddtrace_profiling_context noop_get_profiling_context(void) { + return (ddtrace_profiling_context){0, 0}; +} + +static ddtrace_profiling_context datadog_php_profiling_get_context(void); + +static ddtrace_profiling_context (*datadog_php_profiling_get_legacy_context)(void) = + noop_get_profiling_context; + +#ifdef __linux__ +#define DDOG_PHP_PROF_OTEL_TLS_SYMBOL "otel_thread_ctx_v1" + +typedef struct ddog_php_prof_otel_thread_context_record { + uint8_t trace_id[16]; + uint8_t span_id[8]; + uint8_t valid; + uint8_t reserved; + uint16_t attrs_data_size; + uint8_t attrs_data[DDOG_PHP_PROF_OTEL_ATTRS_DATA_SIZE]; +} ddog_php_prof_otel_thread_context_record; + +_Static_assert(sizeof(ddog_php_prof_otel_thread_context_record) == 640, + "unexpected OTel thread context record size"); +_Static_assert(_Alignof(ddog_php_prof_otel_thread_context_record) == 2, + "unexpected OTel thread context record alignment"); +_Static_assert(offsetof(ddog_php_prof_otel_thread_context_record, trace_id) == 0, + "unexpected OTel thread context trace_id offset"); +_Static_assert(offsetof(ddog_php_prof_otel_thread_context_record, span_id) == 16, + "unexpected OTel thread context span_id offset"); +_Static_assert(offsetof(ddog_php_prof_otel_thread_context_record, valid) == 24, + "unexpected OTel thread context valid offset"); +_Static_assert(offsetof(ddog_php_prof_otel_thread_context_record, reserved) == 25, + "unexpected OTel thread context reserved offset"); +_Static_assert(offsetof(ddog_php_prof_otel_thread_context_record, attrs_data_size) == 26, + "unexpected OTel thread context attrs_data_size offset"); +_Static_assert(offsetof(ddog_php_prof_otel_thread_context_record, attrs_data_size) % _Alignof(uint16_t) == 0, + "unexpected OTel thread context attrs_data_size alignment"); +_Static_assert(offsetof(ddog_php_prof_otel_thread_context_record, attrs_data) == 28, + "unexpected OTel thread context attrs_data offset"); + +static __thread void **ddog_php_prof_otel_thread_ctx_slot = NULL; +static atomic_bool ddog_php_prof_otel_thread_ctx_symbol_available = false; + +static uint64_t ddog_php_prof_read_u64_be(const uint8_t src[8]) { + uint64_t be_value; + memcpy(&be_value, src, sizeof(be_value)); + +#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ + return __builtin_bswap64(be_value); +#elif __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__ + return be_value; +#else +#error "Unsupported byte order" +#endif +} + +bool ddog_php_prof_read_otel_context(ddog_php_prof_otel_context *context) { + if (!ddog_php_prof_otel_thread_ctx_slot) { + return false; + } + + ddog_php_prof_otel_thread_context_record *record = + (ddog_php_prof_otel_thread_context_record *)*ddog_php_prof_otel_thread_ctx_slot; + if (!record || record->valid != 1) { + return false; + } + + context->span_id = ddog_php_prof_read_u64_be(record->span_id); + context->attrs_data_size = record->attrs_data_size; + if (context->attrs_data_size > DDOG_PHP_PROF_OTEL_ATTRS_DATA_SIZE) { + return false; + } + memcpy(context->attrs_data, record->attrs_data, context->attrs_data_size); + + return true; +} + +static ddtrace_profiling_context ddog_php_prof_read_otel_profiling_context(void) { + ddtrace_profiling_context context = {0, 0}; + ddog_php_prof_otel_context otel_context; + if (!ddog_php_prof_read_otel_context(&otel_context)) { + return context; + } + + context.span_id = otel_context.span_id; + + return context; +} + +static void *ddog_php_prof_find_otel_thread_ctx_symbol(void) { + return dlsym(RTLD_DEFAULT, DDOG_PHP_PROF_OTEL_TLS_SYMBOL); +} + +static void ddog_php_prof_init_otel_thread_ctx_slot(void) { + if (ddog_php_prof_otel_thread_ctx_slot) { + return; + } + + ddog_php_prof_otel_thread_ctx_slot = + (void **)ddog_php_prof_find_otel_thread_ctx_symbol(); + if (ddog_php_prof_otel_thread_ctx_slot) { + atomic_store_explicit( + &ddog_php_prof_otel_thread_ctx_symbol_available, + true, + memory_order_relaxed + ); + } +} + +static bool ddog_php_prof_otel_thread_ctx_slot_is_valid(void) { + if (!atomic_load_explicit( + &ddog_php_prof_otel_thread_ctx_symbol_available, + memory_order_relaxed + )) { + return true; + } + + if (ddog_php_prof_otel_thread_ctx_slot) { + return true; + } + + php_log_err( + "Datadog Profiling failed to initialize the OTel thread context TLS slot for this PHP thread" + ); + return false; +} +#else +bool ddog_php_prof_read_otel_context(ddog_php_prof_otel_context *context) { + (void) context; + return false; +} +#endif static void locate_datadog_runtime_id(const zend_extension *extension) { datadog_runtime_id = DL_FETCH_SYMBOL(extension->handle, "datadog_runtime_id"); @@ -24,7 +165,7 @@ 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"); if (EXPECTED(get_profiling)) { - datadog_php_profiling_get_profiling_context = get_profiling; + datadog_php_profiling_get_legacy_context = get_profiling; } } @@ -40,10 +181,6 @@ static bool is_ddtrace_extension(const zend_extension *ext) { return ext && ext->name && strcmp(ext->name, "ddtrace") == 0; } -static ddtrace_profiling_context noop_get_profiling_context(void) { - return (ddtrace_profiling_context){0, 0}; -} - static zend_string *noop_get_process_tags_serialized(void) { return NULL; } @@ -134,6 +271,10 @@ static post_startup_cb_result ddog_php_prof_post_startup_cb(void) { } } +#ifdef __linux__ + ddog_php_prof_init_otel_thread_ctx_slot(); +#endif + _is_post_startup = true; return SUCCESS; @@ -155,7 +296,8 @@ void datadog_php_profiling_startup(zend_extension *extension) { _ignore_run_time_cache = strcmp(sapi_module.name, "cli") == 0; #endif - datadog_php_profiling_get_profiling_context = noop_get_profiling_context; + datadog_php_profiling_get_profiling_context = datadog_php_profiling_get_context; + datadog_php_profiling_get_legacy_context = noop_get_profiling_context; datadog_php_profiling_get_process_tags_serialized = noop_get_process_tags_serialized; /* Due to the optional dependency on ddtrace, the profiling module will be @@ -181,14 +323,46 @@ void datadog_php_profiling_startup(zend_extension *extension) { #endif } +#ifdef __linux__ +void ddog_php_prof_otel_thread_ctx_ginit(void) { + ddog_php_prof_init_otel_thread_ctx_slot(); +} + +bool ddog_php_prof_otel_thread_ctx_rinit(void) { + return ddog_php_prof_otel_thread_ctx_slot_is_valid(); +} +#endif + +zai_str datadog_php_profiling_context_api_name(void) { +#ifdef __linux__ + if (ddog_php_prof_otel_thread_ctx_slot) { + return ddog_php_prof_context_api_otel; + } +#endif + if (datadog_php_profiling_get_legacy_context != noop_get_profiling_context) { + return datadog_php_profiling_context_api_legacy; + } + return datadog_php_profiling_context_api_none; +} + void *datadog_php_profiling_vm_interrupt_addr(void) { return &EG(vm_interrupt); } zend_module_entry *datadog_get_module_entry(const char *str, uintptr_t len) { return zend_hash_str_find_ptr(&module_registry, str, len); } +static ddtrace_profiling_context datadog_php_profiling_get_context(void) { +#ifdef __linux__ + ddtrace_profiling_context otel_context = ddog_php_prof_read_otel_profiling_context(); + if (otel_context.local_root_span_id || otel_context.span_id) { + return otel_context; + } +#endif + return datadog_php_profiling_get_legacy_context(); +} + ddtrace_profiling_context (*datadog_php_profiling_get_profiling_context)(void) = - noop_get_profiling_context; + datadog_php_profiling_get_context; zend_string *(*datadog_php_profiling_get_process_tags_serialized)(void) = noop_get_process_tags_serialized; diff --git a/profiling/src/php_ffi.h b/profiling/src/php_ffi.h index 558c3de4413..2ca980cc371 100644 --- a/profiling/src/php_ffi.h +++ b/profiling/src/php_ffi.h @@ -20,6 +20,8 @@ #include #endif +#define DDOG_PHP_PROF_OTEL_ATTRS_DATA_SIZE 612 + // Needed for `zend_observer_error_register` starting from PHP 8 #if CFG_ZEND_ERROR_OBSERVER // defined by build.rs #include @@ -73,18 +75,26 @@ zend_module_entry *datadog_get_module_entry(const char *str, uintptr_t len); void *datadog_php_profiling_vm_interrupt_addr(void); /** - * For Code Hotspots, we need the tracer's local root span id and the current - * span id. This is a cross-product struct, so keep it in sync with tracer's - * version of this struct. + * For Code Hotspots, we need the local root span id and the current span id. + * The legacy ddtrace_get_profiling_context ABI also uses this struct, so keep + * it in sync with tracer's version. * todo: re-use the tracer's header? */ typedef struct ddtrace_profiling_context_s { uint64_t local_root_span_id, span_id; } ddtrace_profiling_context; +typedef struct ddog_php_prof_otel_context_s { + uint64_t span_id; + uint16_t attrs_data_size; + uint8_t attrs_data[DDOG_PHP_PROF_OTEL_ATTRS_DATA_SIZE]; +} ddog_php_prof_otel_context; + /** - * A pointer to the tracer's ddtrace_get_profiling_context function if it was - * found, otherwise points to a function which just returns {0, 0}. + * A pointer to the profiling-context function. On Linux it first reads the + * OTel thread-context ABI directly when available, then falls back to the + * tracer's legacy ddtrace_get_profiling_context function if it was found. + * Otherwise it returns {0, 0}. */ extern ddtrace_profiling_context (*datadog_php_profiling_get_profiling_context)(void); @@ -101,6 +111,33 @@ extern zend_string *(*datadog_php_profiling_get_process_tags_serialized)(void); */ void datadog_php_profiling_startup(zend_extension *extension); +#ifdef __linux__ +/** + * Called from the PHP module globals ctor to initialize per-thread profiler FFI + * state. + */ +void ddog_php_prof_otel_thread_ctx_ginit(void); + +/** + * Called by this zend_extension's .activate handler to verify per-thread + * profiler FFI state. Returns false if a provider was found for the process but + * not initialized for this PHP thread. + */ +bool ddog_php_prof_otel_thread_ctx_rinit(void); +#endif + +/** + * Copies the current OTel thread context for Rust-side decoding. Returns false + * when the OTel TLS slot is unavailable, empty, or currently invalid. + */ +bool ddog_php_prof_read_otel_context(ddog_php_prof_otel_context *context); + +/** + * Returns the profiling context API selected for this request, or "none" when + * no provider was found. + */ +zai_str datadog_php_profiling_context_api_name(void); + /** * Used to hold information for overwriting the internal function handler * pointer in the Zend Engine. diff --git a/profiling/src/profiling/mod.rs b/profiling/src/profiling/mod.rs index c3d5a53a22d..632d30ccbc2 100644 --- a/profiling/src/profiling/mod.rs +++ b/profiling/src/profiling/mod.rs @@ -165,6 +165,206 @@ pub struct Label { pub value: LabelValue, } +#[derive(Debug, Clone, Default)] +struct ProfileTagOverrides { + service: Option, + env: Option, + version: Option, +} + +impl ProfileTagOverrides { + fn is_empty(&self) -> bool { + self.service.is_none() && self.env.is_none() && self.version.is_none() + } +} + +#[derive(Debug, Clone)] +struct SampleContext { + labels: Vec