diff --git a/Cargo.lock b/Cargo.lock index e99f11eddf4..e375c235703 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1325,11 +1325,14 @@ dependencies = [ "libdd-common-ffi", "libdd-crashtracker-ffi", "libdd-data-pipeline", + "libdd-library-config", "libdd-library-config-ffi", + "libdd-otel-thread-ctx-ffi", "libdd-remote-config", "libdd-telemetry", "libdd-telemetry-ffi", "libdd-tinybytes", + "libdd-trace-protobuf", "libdd-trace-stats", "libdd-trace-utils", "log", @@ -1372,6 +1375,7 @@ dependencies = [ "libc 0.2.177", "libdd-alloc", "libdd-common", + "libdd-library-config", "libdd-library-config-ffi", "libdd-profiling", "log", @@ -2779,7 +2783,7 @@ dependencies = [ [[package]] name = "libdd-common" -version = "4.2.0" +version = "5.0.0" dependencies = [ "anyhow", "bytes", @@ -2923,6 +2927,7 @@ dependencies = [ "libdd-trace-protobuf", "libdd-trace-stats", "libdd-trace-utils", + "prost", "rand 0.8.5", "regex", "rmp-serde", @@ -2963,7 +2968,7 @@ dependencies = [ [[package]] name = "libdd-library-config" -version = "2.0.0" +version = "2.1.0" dependencies = [ "anyhow", "libc 0.2.177", @@ -3014,6 +3019,23 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "libdd-otel-thread-ctx" +version = "1.0.0" +dependencies = [ + "build_common", + "cc", +] + +[[package]] +name = "libdd-otel-thread-ctx-ffi" +version = "1.0.0" +dependencies = [ + "build_common", + "libdd-common-ffi", + "libdd-otel-thread-ctx", +] + [[package]] name = "libdd-profiling" version = "1.0.0" @@ -3071,7 +3093,7 @@ dependencies = [ [[package]] name = "libdd-remote-config" -version = "0.1.0" +version = "1.0.0" dependencies = [ "anyhow", "base64 0.22.1", @@ -3257,6 +3279,7 @@ dependencies = [ "flate2", "futures", "getrandom 0.2.15", + "hex", "http", "http-body", "http-body-util", diff --git a/Makefile b/Makefile index a48ff351cd9..b6e761a035a 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ RUN_TESTS_CMD := DD_SERVICE= DD_ENV= DD_TRACE_RETRY_INTERVAL=1 REPORT_EXIT_STATU C_FILES = $(shell find components components-rs ext src/dogstatsd tracer zend_abstract_interface -name '*.c' -o -name '*.h' | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' ) TEST_FILES = $(shell find tests/ext -name '*.php*' -o -name '*.inc' -o -name '*.json' -o -name '*.yaml' -o -name 'CONFLICTS' | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' ) -RUST_FILES = $(BUILD_DIR)/Cargo.toml $(BUILD_DIR)/Cargo.lock $(shell find components-rs -name '*.c' -o -name '*.rs' -o -name 'Cargo.toml' | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' ) $(shell find libdatadog/{build-common,datadog-ffe,datadog-ipc,datadog-ipc-macros,datadog-live-debugger,datadog-live-debugger-ffi,libdd-remote-config,datadog-sidecar,datadog-sidecar-ffi,datadog-sidecar-macros,libdd-alloc,libdd-capabilities,libdd-capabilities-impl,libdd-common,libdd-common-ffi,libdd-crashtracker,libdd-crashtracker-ffi,libdd-data-pipeline,libdd-ddsketch,libdd-dogstatsd-client,libdd-library-config,libdd-library-config-ffi,libdd-log,libdd-shared-runtime,libdd-telemetry,libdd-telemetry-ffi,libdd-tinybytes,libdd-trace-*,spawn_worker,tools/{cc_utils,sidecar_mockgen},libdd-trace-*,Cargo.toml} \( -type l -o -type f \) \( -path "*/src*" -o -path "*/examples*" -o -path "*Cargo.toml" -o -path "*/build.rs" -o -path "*/tests/dataservice.rs" -o -path "*/tests/service_functional.rs" \) -not -path "*/datadog-ipc/build.rs" -not -path "*/datadog-sidecar-ffi/build.rs") +RUST_FILES = $(BUILD_DIR)/Cargo.toml $(BUILD_DIR)/Cargo.lock $(shell find components-rs -name '*.c' -o -name '*.rs' -o -name 'Cargo.toml' | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' ) $(shell find libdatadog/{build-common,datadog-ffe,datadog-ipc,datadog-ipc-macros,datadog-live-debugger,datadog-live-debugger-ffi,libdd-remote-config,datadog-sidecar,datadog-sidecar-ffi,datadog-sidecar-macros,libdd-alloc,libdd-capabilities,libdd-capabilities-impl,libdd-common,libdd-common-ffi,libdd-crashtracker,libdd-crashtracker-ffi,libdd-data-pipeline,libdd-ddsketch,libdd-dogstatsd-client,libdd-library-config,libdd-library-config-ffi,libdd-log,libdd-otel-thread-ctx*,libdd-shared-runtime,libdd-telemetry,libdd-telemetry-ffi,libdd-tinybytes,libdd-trace-*,spawn_worker,tools/{cc_utils,sidecar_mockgen},libdd-trace-*,Cargo.toml} \( -type l -o -type f \) \( -path "*/src*" -o -path "*/examples*" -o -path "*Cargo.toml" -o -path "*/build.rs" -o -path "*/tests/dataservice.rs" -o -path "*/tests/service_functional.rs" -o -name "tls-dynamic-list.txt" \) -not -path "*/datadog-ipc/build.rs" -not -path "*/datadog-sidecar-ffi/build.rs") ALL_OBJECT_FILES = $(C_FILES) $(RUST_FILES) $(BUILD_DIR)/Makefile TEST_OPCACHE_FILES = $(shell find tests/opcache -name '*.php*' -o -name '.gitkeep' | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' ) TEST_STUB_FILES = $(shell find tests/ext -type d -name 'stubs' -exec find '{}' -type f \; | awk '{ printf "$(BUILD_DIR)/%s\n", $$1 }' ) @@ -423,9 +423,9 @@ clang_format_fix: cbindgen: remove_cbindgen generate_cbindgen remove_cbindgen: - rm -f components-rs/datadog.h components-rs/live-debugger.h components-rs/telemetry.h components-rs/sidecar.h components-rs/common.h components-rs/crashtracker.h components-rs/library-config.h + rm -f components-rs/datadog.h components-rs/live-debugger.h components-rs/telemetry.h components-rs/sidecar.h components-rs/common.h components-rs/crashtracker.h components-rs/library-config.h components-rs/otel-thread-ctx.h -generate_cbindgen: cbindgen_binary # Regenerate components-rs/datadog.h components-rs/live-debugger.h components-rs/telemetry.h components-rs/sidecar.h components-rs/common.h components-rs/crashtracker.h components-rs/library-config.h +generate_cbindgen: cbindgen_binary # Regenerate components-rs/datadog.h components-rs/live-debugger.h components-rs/telemetry.h components-rs/sidecar.h components-rs/common.h components-rs/crashtracker.h components-rs/library-config.h components-rs/otel-thread-ctx.h ( \ $(command rustup && echo run nightly --) cbindgen --crate datadog-php \ --config cbindgen.toml \ @@ -449,11 +449,14 @@ generate_cbindgen: cbindgen_binary # Regenerate components-rs/datadog.h componen $(command rustup && echo run nightly --) cbindgen --crate libdd-library-config-ffi \ --config libdd-library-config-ffi/cbindgen.toml \ --output $(PROJECT_ROOT)/components-rs/library-config.h; \ + $(command rustup && echo run nightly --) cbindgen --crate libdd-otel-thread-ctx-ffi \ + --config libdd-otel-thread-ctx-ffi/cbindgen.toml \ + --output $(PROJECT_ROOT)/components-rs/otel-thread-ctx.h; \ if test -d $(PROJECT_ROOT)/tmp; then \ mkdir -pv "$(BUILD_DIR)"; \ export CARGO_TARGET_DIR="$(BUILD_DIR)/target"; \ fi; \ - cargo run -p tools --bin dedup_headers -- $(PROJECT_ROOT)/components-rs/common.h $(PROJECT_ROOT)/components-rs/datadog.h $(PROJECT_ROOT)/components-rs/live-debugger.h $(PROJECT_ROOT)/components-rs/telemetry.h $(PROJECT_ROOT)/components-rs/sidecar.h $(PROJECT_ROOT)/components-rs/crashtracker.h $(PROJECT_ROOT)/components-rs/library-config.h \ + cargo run -p tools --bin dedup_headers -- $(PROJECT_ROOT)/components-rs/common.h $(PROJECT_ROOT)/components-rs/datadog.h $(PROJECT_ROOT)/components-rs/live-debugger.h $(PROJECT_ROOT)/components-rs/telemetry.h $(PROJECT_ROOT)/components-rs/sidecar.h $(PROJECT_ROOT)/components-rs/crashtracker.h $(PROJECT_ROOT)/components-rs/library-config.h $(PROJECT_ROOT)/components-rs/otel-thread-ctx.h \ ) cbindgen_binary: 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..46cc3d2b49a --- /dev/null +++ b/appsec/tests/integration/src/test/groovy/com/datadog/appsec/php/integration/OtelThreadContextTests.groovy @@ -0,0 +1,286 @@ +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 GDB_SCRIPT = + '/project/appsec/tests/integration/src/test/resources/otel_context_gdb.py' + private static final String GDB_TIMEOUT = '20s' + private static final String LOCAL_ROOT_SPAN_ID_ATTRIBUTE_KEY = 'datadog.local_root_span_id' + private static final String SERVICE_NAME_ATTRIBUTE_KEY = 'service.name' + private static final String SERVICE_VERSION_ATTRIBUTE_KEY = 'service.version' + private static final String DEPLOYMENT_ENV_ATTRIBUTE_KEY = 'deployment.environment.name' + private static final String EXPECTED_SERVICE_NAME = 'appsec_int_tests' + private static final String EXPECTED_SERVICE_VERSION = 'otel-context-test' + private static final String EXPECTED_ENV = 'integration' + private static final String EXPECTED_ATTRIBUTE_KEY_MAP = [ + LOCAL_ROOT_SPAN_ID_ATTRIBUTE_KEY, + SERVICE_NAME_ATTRIBUTE_KEY, + SERVICE_VERSION_ATTRIBUTE_KEY, + DEPLOYMENT_ENV_ATTRIBUTE_KEY, + ].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_VERSION', EXPECTED_SERVICE_VERSION) + + static void main(String[] args) { + InspectContainerHelper.run(CONTAINER) + } + + @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) + } + } + } + + @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) + + assert responseBody.outer_span_id != responseBody.span_id + assertThreadContextMatchesResponse(threadContext, [ + waited: responseBody.waited, + trace_id: responseBody.outer_trace_id, + span_id: responseBody.outer_span_id, + local_root_span_id: responseBody.outer_local_root_span_id, + ]) + } finally { + if (!requestContinued) { + continuePausedRequestQuietly(pausedRequest.pid) + } + } + } + + @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'] == EXPECTED_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[LOCAL_ROOT_SPAN_ID_ATTRIBUTE_KEY] == responseBody.local_root_span_id + assert threadContext[SERVICE_NAME_ATTRIBUTE_KEY] == EXPECTED_SERVICE_NAME + assert threadContext[SERVICE_VERSION_ATTRIBUTE_KEY] == EXPECTED_SERVICE_VERSION + assert threadContext[DEPLOYMENT_ENV_ATTRIBUTE_KEY] == EXPECTED_ENV + } + + 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) + + 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) { + 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', + "test -s ${PID_FILE} && cat ${PID_FILE} || true".toString()) + 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 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_regular.php b/appsec/tests/integration/src/test/www/base/public/otel_context_regular.php new file mode 100644 index 00000000000..5e163cddcf0 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context_regular.php @@ -0,0 +1,18 @@ + '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(), +]); 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..fb76f22a784 --- /dev/null +++ b/appsec/tests/integration/src/test/www/base/public/otel_context_user_request.php @@ -0,0 +1,43 @@ +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, +]); + +if ($outerSpan) { + \DDTrace\switch_stack($outerSpan); +} + +$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_trace_id' => $outerSpan ? $outerSpan->traceId : null, + 'outer_span_id' => $outerSpan ? $outerSpan->hexId() : null, + 'outer_local_root_span_id' => $outerSpan ? $outerSpan->hexId() : null, +]; + +\DDTrace\switch_stack($userRequestSpan); +\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/compile_rust.sh b/compile_rust.sh index fe62f8c304e..85069e7713d 100755 --- a/compile_rust.sh +++ b/compile_rust.sh @@ -13,6 +13,33 @@ case "${host_os}" in ;; esac +# GCC < 9 doesn't support -fuse-ld=lld (emitted by libdd-otel-thread-ctx-ffi/build.rs). +# Intercept CC calls and replace -fuse-ld=lld with -B where /ld -> ld.lld. +_gcc_major=$(cc -dumpversion 2>/dev/null | cut -d. -f1) +if [ -n "${_gcc_major}" ] && [ "${_gcc_major:-99}" -lt 9 ] 2>/dev/null; then + _sysroot=$(rustc --print sysroot 2>/dev/null) + _tgt=$(rustc -vV 2>/dev/null | sed -n 's/^host: //p') + _lld="${_sysroot}/lib/rustlib/${_tgt}/bin/gcc-ld/ld.lld" + if [ -x "${_lld}" ]; then + _wd=$(mktemp -d) + ln -sf "${_lld}" "${_wd}/ld" + _real_cc=$(command -v cc) + cat > "${_wd}/cc" << EOF +#!/bin/sh +_a= +for _x in "\$@"; do + case "\$_x" in + -fuse-ld=lld) _a="\$_a -B${_wd}" ;; + *) _a="\$_a \$_x" ;; + esac +done +exec ${_real_cc} \$_a +EOF + chmod +x "${_wd}/cc" + export PATH="${_wd}:${PATH}" + fi +fi + set -x if test -n "$COMPILE_ASAN"; then diff --git a/components-rs/Cargo.toml b/components-rs/Cargo.toml index c4b8de1c341..211fa6db150 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" } @@ -54,6 +56,9 @@ libc = "0.2" bincode = { version = "1.3.3" } hashbrown = "0.15" +[target.'cfg(target_os = "linux")'.dependencies] +libdd-otel-thread-ctx-ffi = { path = "../libdatadog/libdd-otel-thread-ctx-ffi", default-features = false } + [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..857ef09d6d1 100644 --- a/components-rs/datadog.h +++ b/components-rs/datadog.h @@ -41,6 +41,14 @@ void datadog_generate_session_id(void); void datadog_format_runtime_id(uint8_t (*buf)[36]); +#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 + ddog_CharSlice ddtrace_get_container_id(void); void ddtrace_set_container_cgroup_path(ddog_CharSlice path); @@ -61,7 +69,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 +120,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 +483,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..74bd0ff25e0 100644 --- a/components-rs/lib.rs +++ b/components-rs/lib.rs @@ -13,25 +13,37 @@ 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::*; + +#[cfg(target_os = "linux")] +pub use libdd_otel_thread_ctx_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; 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; +#[cfg(target_os = "linux")] +use libdd_trace_protobuf::opentelemetry::proto::{ + common::v1::{any_value, AnyValue, ArrayValue, KeyValue, ProcessContext}, + resource::v1::Resource, +}; #[no_mangle] #[allow(non_upper_case_globals)] @@ -92,6 +104,82 @@ 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()) + }; + + fn key_value(key: &'static str, value: String) -> KeyValue { + KeyValue { + key: key.to_owned(), + value: Some(AnyValue { + value: Some(any_value::Value::StringValue(value)), + }), + key_ref: 0, + } + } + + let mut attributes = vec![ + key_value("telemetry.sdk.language", "php".to_owned()), + key_value( + "telemetry.sdk.version", + include_str!("../VERSION").trim().to_owned(), + ), + key_value("telemetry.sdk.name", "libdatadog".to_owned()), + key_value("host.name", hostname.to_utf8_lossy().into_owned()), + key_value("threadlocal.schema_version", "tlsdesc_v1_dev".to_owned()), + KeyValue { + key: "threadlocal.attribute_key_map".to_owned(), + value: Some(AnyValue { + value: Some(any_value::Value::ArrayValue(ArrayValue { + values: [ + "datadog.local_root_span_id", + "service.name", + "service.version", + "deployment.environment.name", + ] + .into_iter() + .map(|value| AnyValue { + value: Some(any_value::Value::StringValue(value.to_owned())), + }) + .collect(), + })), + }), + key_ref: 0, + }, + ]; + + if let Some(runtime_id) = runtime_id { + attributes.push(key_value("service.instance.id", runtime_id)); + } + + let context = ProcessContext { + resource: Some(Resource { + attributes, + dropped_attributes_count: 0, + entity_refs: vec![], + }), + // The generated protobuf still uses the old OTEP name for field 2. + extra_attributes: vec![], + }; + + match otel_process_ctx::linux::publish(&context) { + 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 +} + #[must_use] #[no_mangle] pub extern "C" fn ddtrace_get_container_id() -> CharSlice<'static> { diff --git a/components-rs/otel-thread-ctx.h b/components-rs/otel-thread-ctx.h new file mode 100644 index 00000000000..aef2811f798 --- /dev/null +++ b/components-rs/otel-thread-ctx.h @@ -0,0 +1,153 @@ +// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/ +// SPDX-License-Identifier: Apache-2.0 + + +#ifndef DDOG_OTEL_THREAD_CTX_H +#define DDOG_OTEL_THREAD_CTX_H + +#pragma once + +#include +#include +#include +#include "common.h" + +#if defined(__linux__) +#endif + +#if defined(__linux__) +#endif + +#if defined(__linux__) +#endif + +#if defined(__linux__) +#endif + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +#if defined(__linux__) +/** + * Allocate and initialise a new thread context. + * + * Returns a non-null owned handle that must eventually be released with + * `ddog_otel_thread_ctx_free`. + */ +struct ddog_ThreadContextHandle *ddog_otel_thread_ctx_new(const uint8_t (*trace_id)[16], + const uint8_t (*span_id)[8], + const uint8_t (*local_root_span_id)[8]); +#endif + +#if defined(__linux__) +struct ddog_ThreadContextHandle *ddog_otel_thread_ctx_new_with_attrs(const uint8_t (*trace_id)[16], + const uint8_t (*span_id)[8], + const uint8_t (*local_root_span_id)[8], + const struct ddog_OtelThreadContextAttribute *attrs, + uintptr_t attrs_len); +#endif + +#if defined(__linux__) +bool ddog_otel_thread_ctx_record_update(struct ddog_ThreadContextRecord *ctx, + const uint8_t (*trace_id)[16], + const uint8_t (*span_id)[8], + const uint8_t (*local_root_span_id)[8], + const struct ddog_OtelThreadContextAttribute *attrs, + uintptr_t attrs_len); +#endif + +#if defined(__linux__) +bool ddog_otel_thread_ctx_record_update_span_id(struct ddog_ThreadContextRecord *ctx, + const uint8_t (*span_id)[8]); +#endif + +#if defined(__linux__) +/** + * Free an owned thread context. + * + * # Safety + * + * `ctx` must be a valid non-null pointer obtained from `ddog_otel_thread_ctx_new` or + * `ddog_otel_thread_ctx_detach`, and must not be used after this call. In particular, `ctx` + * must not be currently attached to a thread. + */ +void ddog_otel_thread_ctx_free(struct ddog_ThreadContextHandle *ctx); +#endif + +#if defined(__linux__) +/** + * Attach `ctx` to the current thread. Returns the previously attached context if any, or null + * otherwise. + * + * # Safety + * + * `ctx` must be a valid non-null pointer obtained from this API. Ownership of `ctx` is + * transferred to the TLS slot: the caller must not drop `ctx` while it is still actively + * attached. + */ +struct ddog_ThreadContextHandle *ddog_otel_thread_ctx_attach(struct ddog_ThreadContextHandle *ctx); +#endif + +#if defined(__linux__) +/** + * Attach an externally owned record to the current thread without taking ownership. + * + * # Safety + * + * `ctx` must point to a live record that remains allocated until it is detached. + */ +void ddog_otel_thread_ctx_attach_record(struct ddog_ThreadContextRecord *ctx); +#endif + +#if defined(__linux__) +/** + * Remove the currently attached context from the TLS slot. + * + * Returns the detached context (caller now owns it and must release it with + * `ddog_otel_thread_ctx_free`), or null if the slot was empty. + */ +struct ddog_ThreadContextHandle *ddog_otel_thread_ctx_detach(void); +#endif + +#if defined(__linux__) +/** + * Clear the current thread's context slot without taking ownership of the previous record. + */ +void ddog_otel_thread_ctx_detach_record(void); +#endif + +#if defined(__linux__) +/** + * Clear the current thread's context slot if it currently points to `ctx`. + * + * Returns true when the slot was cleared. + */ +bool ddog_otel_thread_ctx_detach_record_if_current(struct ddog_ThreadContextRecord *ctx); +#endif + +#if defined(__linux__) +/** + * Update the currently attached context in-place. + * + * If no context is currently attached, one is created and attached, equivalent to calling + * `ddog_otel_thread_ctx_new` followed by `ddog_otel_thread_ctx_attach`. + */ +void ddog_otel_thread_ctx_update(const uint8_t (*trace_id)[16], + const uint8_t (*span_id)[8], + const uint8_t (*local_root_span_id)[8]); +#endif + +#if defined(__linux__) +void ddog_otel_thread_ctx_update_with_attrs(const uint8_t (*trace_id)[16], + const uint8_t (*span_id)[8], + const uint8_t (*local_root_span_id)[8], + const struct ddog_OtelThreadContextAttribute *attrs, + uintptr_t attrs_len); +#endif + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif /* DDOG_OTEL_THREAD_CTX_H */ diff --git a/config.m4 b/config.m4 index aa69d309a43..0bc79e7e5c8 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 \ 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..9e86ca34e33 100644 --- a/datadog.sym +++ b/datadog.sym @@ -1,6 +1,7 @@ ddtrace_close_all_spans_and_flush datadog_get_formatted_session_id ddtrace_get_profiling_context +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..781d59dfb09 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,37 @@ static void datadog_shutdown(zend_extension *extension) { #endif } +static ddog_CharSlice datadog_otel_process_context_hostname(void) { + if (!get_DD_TRACE_REPORT_HOSTNAME()) { + return DDOG_CHARSLICE_C(""); + } + + if (ZSTR_LEN(get_DD_HOSTNAME())) { + return dd_zend_string_to_CharSlice(get_DD_HOSTNAME()); + } + + // Match tracer/serializer.c hostname publishing: DD_HOSTNAME wins, then gethostname(). +#ifdef __linux__ +#ifndef HOST_NAME_MAX +#define HOST_NAME_MAX 255 +#endif + static char hostname[HOST_NAME_MAX + 1]; + if (gethostname(hostname, sizeof(hostname)) != 0) { + return DDOG_CHARSLICE_C(""); + } + hostname[HOST_NAME_MAX] = '\0'; + return (ddog_CharSlice){.ptr = hostname, .len = strlen(hostname)}; +#else + return DDOG_CHARSLICE_C(""); +#endif +} + 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_otel_process_context(datadog_otel_process_context_hostname()); // must run before the first zai_hook_activate as tracer telemetry setup installs a global hook if (!datadog_disable) { diff --git a/libdatadog b/libdatadog index cd90e50a5b0..993bb73dbe7 160000 --- a/libdatadog +++ b/libdatadog @@ -1 +1 @@ -Subproject commit cd90e50a5b067cf77a3e06641d838bc4c6b62aba +Subproject commit 993bb73dbe7cd6f07cd39325a4f2ed047502b7fc 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..230a4fae8c3 100644 --- a/profiling/build.rs +++ b/profiling/build.rs @@ -275,6 +275,8 @@ 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("datadog_php_profiling_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..b8cedeff4d8 100644 --- a/profiling/src/bindings/mod.rs +++ b/profiling/src/bindings/mod.rs @@ -340,6 +340,14 @@ 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 a request. + pub fn datadog_php_profiling_rinit(); + + /// 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..71d6758e38c 100644 --- a/profiling/src/lib.rs +++ b/profiling/src/lib.rs @@ -584,6 +584,8 @@ extern "C" fn rinit(_type: c_int, _module_number: c_int) -> ZendResult { let result = REQUEST_LOCALS.try_with_borrow_mut(|locals| { // SAFETY: we are in rinit on a PHP thread. locals.vm_interrupt_addr = unsafe { zend::datadog_php_profiling_vm_interrupt_addr() }; + // SAFETY: we are in rinit on a PHP thread. + unsafe { zend::datadog_php_profiling_rinit() }; // SAFETY: We are after first rinit and before mshutdown. unsafe { @@ -663,6 +665,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/php_ffi.c b/profiling/src/php_ffi.c index 1e906f3cbd3..b3450905042 100644 --- a/profiling/src/php_ffi.c +++ b/profiling/src/php_ffi.c @@ -7,14 +7,154 @@ #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 datadog_php_profiling_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 DATADOG_PHP_PROFILING_OTEL_TLS_SYMBOL "otel_thread_ctx_v1" + +typedef struct datadog_php_profiling_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[DATADOG_PHP_PROFILING_OTEL_ATTRS_DATA_SIZE]; +} datadog_php_profiling_otel_thread_context_record; + +_Static_assert(sizeof(datadog_php_profiling_otel_thread_context_record) == 640, + "unexpected OTel thread context record size"); +_Static_assert(_Alignof(datadog_php_profiling_otel_thread_context_record) == 2, + "unexpected OTel thread context record alignment"); +_Static_assert(offsetof(datadog_php_profiling_otel_thread_context_record, trace_id) == 0, + "unexpected OTel thread context trace_id offset"); +_Static_assert(offsetof(datadog_php_profiling_otel_thread_context_record, span_id) == 16, + "unexpected OTel thread context span_id offset"); +_Static_assert(offsetof(datadog_php_profiling_otel_thread_context_record, valid) == 24, + "unexpected OTel thread context valid offset"); +_Static_assert(offsetof(datadog_php_profiling_otel_thread_context_record, reserved) == 25, + "unexpected OTel thread context reserved offset"); +_Static_assert(offsetof(datadog_php_profiling_otel_thread_context_record, attrs_data_size) == 26, + "unexpected OTel thread context attrs_data_size offset"); +_Static_assert(offsetof(datadog_php_profiling_otel_thread_context_record, attrs_data_size) % _Alignof(uint16_t) == 0, + "unexpected OTel thread context attrs_data_size alignment"); +_Static_assert(offsetof(datadog_php_profiling_otel_thread_context_record, attrs_data) == 28, + "unexpected OTel thread context attrs_data offset"); + +static __thread void **datadog_php_profiling_otel_thread_ctx_slot = NULL; + +static inline uint64_t datadog_php_profiling_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 datadog_php_profiling_read_otel_context(datadog_php_profiling_otel_context *context) { + if (!datadog_php_profiling_otel_thread_ctx_slot) { + return false; + } + + datadog_php_profiling_otel_thread_context_record *record = + (datadog_php_profiling_otel_thread_context_record *)*datadog_php_profiling_otel_thread_ctx_slot; + /* + * The writer is stopped while this signal-handler-style reader runs on the same thread, so + * valid only has to guard against compiler reordering. The writer fences before/after field + * updates; this reader fences after observing valid=1 so field reads stay after the guard. + */ + if (!record || record->valid != 1) { + return false; + } + + atomic_signal_fence(memory_order_acquire); + + context->span_id = datadog_php_profiling_read_u64_be(record->span_id); + context->attrs_data_size = record->attrs_data_size; + if (context->attrs_data_size > DATADOG_PHP_PROFILING_OTEL_ATTRS_DATA_SIZE) { + return false; + } + memcpy(context->attrs_data, record->attrs_data, context->attrs_data_size); + + return true; +} + +static ddtrace_profiling_context datadog_php_profiling_read_otel_profiling_context(void) { + ddtrace_profiling_context context = {0, 0}; + datadog_php_profiling_otel_context otel_context; + if (!datadog_php_profiling_read_otel_context(&otel_context)) { + return context; + } + + context.span_id = otel_context.span_id; + + return context; +} + +static void *datadog_php_profiling_find_otel_thread_ctx_symbol(void) { + void *tls_symbol = dlsym(RTLD_DEFAULT, DATADOG_PHP_PROFILING_OTEL_TLS_SYMBOL); + if (tls_symbol) { + return tls_symbol; + } + + const zend_llist *extensions = &zend_extensions; + for (const zend_llist_element *item = extensions->head; item; item = item->next) { + const zend_extension *extension = (zend_extension *)item->data; + if (extension && extension->handle) { + tls_symbol = DL_FETCH_SYMBOL(extension->handle, DATADOG_PHP_PROFILING_OTEL_TLS_SYMBOL); + if (tls_symbol) { + return tls_symbol; + } + } + } + + zend_module_entry *module; + ZEND_HASH_FOREACH_PTR(&module_registry, module) { + if (module && module->handle) { + tls_symbol = DL_FETCH_SYMBOL(module->handle, DATADOG_PHP_PROFILING_OTEL_TLS_SYMBOL); + if (tls_symbol) { + return tls_symbol; + } + } + } ZEND_HASH_FOREACH_END(); + + return NULL; +} +#else +bool datadog_php_profiling_read_otel_context(datadog_php_profiling_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 +164,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 +180,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; } @@ -155,7 +291,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 +318,43 @@ void datadog_php_profiling_startup(zend_extension *extension) { #endif } +void datadog_php_profiling_rinit(void) { +#ifdef __linux__ + datadog_php_profiling_otel_thread_ctx_slot = + (void **)datadog_php_profiling_find_otel_thread_ctx_symbol(); +#endif +} + +zai_str datadog_php_profiling_context_api_name(void) { +#ifdef __linux__ + if (datadog_php_profiling_otel_thread_ctx_slot) { + return datadog_php_profiling_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 = datadog_php_profiling_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..d3728709dc2 100644 --- a/profiling/src/php_ffi.h +++ b/profiling/src/php_ffi.h @@ -20,6 +20,8 @@ #include #endif +#define DATADOG_PHP_PROFILING_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 datadog_php_profiling_otel_context_s { + uint64_t span_id; + uint16_t attrs_data_size; + uint8_t attrs_data[DATADOG_PHP_PROFILING_OTEL_ATTRS_DATA_SIZE]; +} datadog_php_profiling_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,24 @@ extern zend_string *(*datadog_php_profiling_get_process_tags_serialized)(void); */ void datadog_php_profiling_startup(zend_extension *extension); +/** + * Called by this zend_extension's .activate handler to initialize per-thread + * profiler FFI state. + */ +void datadog_php_profiling_rinit(void); + +/** + * Copies the current OTel thread context for Rust-side decoding. Returns false + * when the OTel TLS slot is unavailable, empty, or currently invalid. + */ +bool datadog_php_profiling_read_otel_context(datadog_php_profiling_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..3f997954579 100644 --- a/profiling/src/profiling/mod.rs +++ b/profiling/src/profiling/mod.rs @@ -165,6 +165,127 @@ 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