diff --git a/config.m4 b/config.m4 index 8638e80519..7cfde9c81a 100644 --- a/config.m4 +++ b/config.m4 @@ -201,6 +201,7 @@ if test "$PHP_DDTRACE" != "no"; then ext/memory_limit.c \ ext/otel_config.c \ ext/priority_sampling/priority_sampling.c \ + ext/process_tags.c \ ext/profiling.c \ ext/random.c \ ext/remote_config.c \ diff --git a/config.w32 b/config.w32 index 1f964734d1..977b4f6f60 100644 --- a/config.w32 +++ b/config.w32 @@ -48,6 +48,7 @@ if (PHP_DDTRACE != 'no') { DDTRACE_EXT_SOURCES += " logging.c"; DDTRACE_EXT_SOURCES += " memory_limit.c"; DDTRACE_EXT_SOURCES += " otel_config.c"; + DDTRACE_EXT_SOURCES += " process_tags.c"; DDTRACE_EXT_SOURCES += " profiling.c"; DDTRACE_EXT_SOURCES += " random.c"; DDTRACE_EXT_SOURCES += " remote_config.c"; diff --git a/ext/configuration.h b/ext/configuration.h index d5c9ed0f86..ec2c559b01 100644 --- a/ext/configuration.h +++ b/ext/configuration.h @@ -258,6 +258,7 @@ enum ddtrace_sampling_rules_format { CONFIG(SET, DD_TRACE_HTTP_SERVER_ERROR_STATUSES, "500-599", .ini_change = zai_config_system_ini_change) \ CONFIG(BOOL, DD_CODE_ORIGIN_FOR_SPANS_ENABLED, "true") \ CONFIG(INT, DD_CODE_ORIGIN_MAX_USER_FRAMES, "8") \ + CONFIG(BOOL, DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED, "false") \ DD_INTEGRATIONS #ifndef _WIN32 diff --git a/ext/ddtrace.c b/ext/ddtrace.c index 1f2e65d822..05d2d41028 100644 --- a/ext/ddtrace.c +++ b/ext/ddtrace.c @@ -68,6 +68,7 @@ #include "limiter/limiter.h" #include "standalone_limiter.h" #include "priority_sampling/priority_sampling.h" +#include "process_tags.h" #include "random.h" #include "autoload_php_files.h" #include "remote_config.h" @@ -1606,6 +1607,7 @@ static PHP_MSHUTDOWN_FUNCTION(ddtrace) { ddtrace_sidecar_shutdown(); ddtrace_live_debugger_mshutdown(); + ddtrace_process_tags_mshutdown(); #if PHP_VERSION_ID >= 80000 && PHP_VERSION_ID < 80100 // See dd_register_span_data_ce for explanation @@ -1628,6 +1630,9 @@ static void dd_rinit_once(void) { */ ddtrace_startup_logging_first_rinit(); + // Collect process tags now that script path is available + ddtrace_process_tags_first_rinit(); + // Uses config, cannot run earlier #ifndef _WIN32 ddtrace_signals_first_rinit(); diff --git a/ext/process_tags.c b/ext/process_tags.c new file mode 100644 index 0000000000..e581c54138 --- /dev/null +++ b/ext/process_tags.c @@ -0,0 +1,243 @@ +#include "process_tags.h" +#include "configuration.h" +#include "ddtrace.h" +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#define getcwd _getcwd +#define PATH_MAX _MAX_PATH +#else +#include +#endif + +#ifndef PATH_MAX +#define PATH_MAX 4096 +#endif + +#define TAG_ENTRYPOINT_NAME "entrypoint.name" +#define TAG_ENTRYPOINT_BASEDIR "entrypoint.basedir" +#define TAG_ENTRYPOINT_WORKDIR "entrypoint.workdir" +#define TAG_ENTRYPOINT_TYPE "entrypoint.type" +#define TAG_SERVER_TYPE "server.type" +#define TYPE_CLI "cli" +#define MAX_PROCESS_TAGS 10 + +typedef struct { + char *key; + char *value; +} process_tag_entry_t; + +typedef struct { + process_tag_entry_t entries[MAX_PROCESS_TAGS]; + size_t count; + zend_string *serialized; +} process_tags_t; + +static process_tags_t process_tags = {0}; + +// Normalize tag value per RFC: lowercase, allow [a-z0-9/.-], replace rest with _ +static char *normalize_value(const char *value) { + if (!value || !*value) { + return NULL; + } + + size_t len = strlen(value); + char *normalized = malloc(len + 1); + if (!normalized) { + return NULL; + } + + for (size_t i = 0; i < len; i++) { + char c = value[i]; + if (c >= 'A' && c <= 'Z') { + normalized[i] = c + ('a' - 'A'); + } else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || + c == '/' || c == '.' || c == '-') { + normalized[i] = c; + } else { + normalized[i] = '_'; + } + } + normalized[len] = '\0'; + return normalized; +} + +static char *get_basename(const char *path) { + if (!path || !*path) { + return NULL; + } + + const char *last_slash = strrchr(path, '/'); +#ifdef _WIN32 + const char *last_backslash = strrchr(path, '\\'); + if (last_backslash && (!last_slash || last_backslash > last_slash)) { + last_slash = last_backslash; + } +#endif + + const char *basename = last_slash ? last_slash + 1 : path; + return *basename ? strdup(basename) : NULL; +} + +static char *get_dirname(const char *path) { + if (!path || !*path) { + return NULL; + } + + char *path_copy = strdup(path); + if (!path_copy) { + return NULL; + } + + char *last_slash = strrchr(path_copy, '/'); +#ifdef _WIN32 + char *last_backslash = strrchr(path_copy, '\\'); + if (last_backslash && (!last_slash || last_backslash > last_slash)) { + last_slash = last_backslash; + } +#endif + + char *basedir; + if (last_slash) { + *last_slash = '\0'; + basedir = get_basename(path_copy); + } else { + basedir = strdup("."); + } + free(path_copy); + return basedir; +} + +static void add_process_tag(const char *key, const char *value) { + if (!key || !value || process_tags.count >= MAX_PROCESS_TAGS) { + return; + } + + char *normalized_value = normalize_value(value); + if (!normalized_value) { + return; + } + + char *key_copy = strdup(key); + if (!key_copy) { + free(normalized_value); + return; + } + + process_tags.entries[process_tags.count].key = key_copy; + process_tags.entries[process_tags.count].value = normalized_value; + process_tags.count++; +} + +static int compare_tags(const void *a, const void *b) { + return strcmp(((const process_tag_entry_t *)a)->key, ((const process_tag_entry_t *)b)->key); +} + +// Serialize process tags as comma-separated key:value pairs, sorted by key +static void serialize_process_tags(void) { + if (process_tags.count == 0) { + return; + } + + qsort(process_tags.entries, process_tags.count, sizeof(process_tag_entry_t), compare_tags); + + size_t total_len = 0; + for (size_t i = 0; i < process_tags.count; i++) { + total_len += strlen(process_tags.entries[i].key) + 1 + strlen(process_tags.entries[i].value); + if (i < process_tags.count - 1) { + total_len++; // comma separator + } + } + + process_tags.serialized = zend_string_alloc(total_len, 1); // persistent allocation + char *ptr = ZSTR_VAL(process_tags.serialized); + + for (size_t i = 0; i < process_tags.count; i++) { + size_t key_len = strlen(process_tags.entries[i].key); + size_t value_len = strlen(process_tags.entries[i].value); + + memcpy(ptr, process_tags.entries[i].key, key_len); + ptr += key_len; + *ptr++ = ':'; + memcpy(ptr, process_tags.entries[i].value, value_len); + ptr += value_len; + if (i < process_tags.count - 1) { + *ptr++ = ','; + } + } + *ptr = '\0'; +} + +static void collect_process_tags(void) { + bool is_cli = (strcmp(sapi_module.name, "cli") == 0 || strcmp(sapi_module.name, "phpdbg") == 0); + char *entrypoint_name = NULL; + char *entrypoint_basedir = NULL; + char *entrypoint_workdir = NULL; + + if (is_cli) { + // CLI: collect script information (not the PHP binary) + if (SG(request_info).path_translated && *SG(request_info).path_translated) { + entrypoint_name = get_basename(SG(request_info).path_translated); + entrypoint_basedir = get_dirname(SG(request_info).path_translated); + } + } else { + // Web SAPI: collect server type (different requests may execute different scripts) + add_process_tag(TAG_SERVER_TYPE, sapi_module.name); + } + + char cwd[PATH_MAX]; + if (getcwd(cwd, sizeof(cwd))) { + entrypoint_workdir = get_basename(cwd); + } + + if (entrypoint_basedir) { + add_process_tag(TAG_ENTRYPOINT_BASEDIR, entrypoint_basedir); + } + if (entrypoint_name) { + add_process_tag(TAG_ENTRYPOINT_NAME, entrypoint_name); + } + if (is_cli) { + add_process_tag(TAG_ENTRYPOINT_TYPE, TYPE_CLI); + } + if (entrypoint_workdir) { + add_process_tag(TAG_ENTRYPOINT_WORKDIR, entrypoint_workdir); + } + + free(entrypoint_name); + free(entrypoint_basedir); + free(entrypoint_workdir); + + serialize_process_tags(); +} + +void ddtrace_process_tags_first_rinit(void) { + if (ddtrace_process_tags_enabled() && !process_tags.serialized) { + collect_process_tags(); + } +} + +void ddtrace_process_tags_mshutdown(void) { + for (size_t i = 0; i < process_tags.count; i++) { + free(process_tags.entries[i].key); + free(process_tags.entries[i].value); + } + if (process_tags.serialized) { + zend_string_release(process_tags.serialized); + } + memset(&process_tags, 0, sizeof(process_tags)); +} + +bool ddtrace_process_tags_enabled(void) { + return get_global_DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED(); +} + +zend_string *ddtrace_process_tags_get_serialized(void) { + return (ddtrace_process_tags_enabled() && process_tags.serialized) ? process_tags.serialized : NULL; +} + diff --git a/ext/process_tags.h b/ext/process_tags.h new file mode 100644 index 0000000000..0ff2767c4e --- /dev/null +++ b/ext/process_tags.h @@ -0,0 +1,22 @@ +#ifndef DD_PROCESS_TAGS_H +#define DD_PROCESS_TAGS_H + +#include +#include + +// Called at first RINIT to collect process tags +void ddtrace_process_tags_first_rinit(void); + +// Called at MSHUTDOWN to free resources +void ddtrace_process_tags_mshutdown(void); + +// Check if process tags propagation is enabled +bool ddtrace_process_tags_enabled(void); + +// Get the serialized process tags (comma-separated, sorted) +// Returns NULL if disabled or not yet collected +zend_string *ddtrace_process_tags_get_serialized(void); + +#endif // DD_PROCESS_TAGS_H + + diff --git a/ext/serializer.c b/ext/serializer.c index 51c4e1eac9..189ed10abb 100644 --- a/ext/serializer.c +++ b/ext/serializer.c @@ -41,6 +41,7 @@ #include "ip_extraction.h" #include #include "priority_sampling/priority_sampling.h" +#include "process_tags.h" #include "span.h" #include "uri_normalization.h" #include "user_request.h" @@ -831,6 +832,16 @@ void ddtrace_set_root_span_properties(ddtrace_root_span_data *span) { } } + // Add process tags if enabled + if (ddtrace_process_tags_enabled()) { + zend_string *process_tags = ddtrace_process_tags_get_serialized(); + if (process_tags && ZSTR_LEN(process_tags) > 0) { + zval process_tags_zv; + ZVAL_STR_COPY(&process_tags_zv, process_tags); + zend_hash_str_add_new(meta, ZEND_STRL("_dd.tags.process"), &process_tags_zv); + } + } + ddtrace_root_span_data *parent_root = span->stack->parent_stack->root_span; if (parent_root) { ddtrace_inherit_span_properties(&span->span, &parent_root->span); diff --git a/tests/Integrations/Custom/Autoloaded/ProcessTagsWebTest.php b/tests/Integrations/Custom/Autoloaded/ProcessTagsWebTest.php new file mode 100644 index 0000000000..97a1d37607 --- /dev/null +++ b/tests/Integrations/Custom/Autoloaded/ProcessTagsWebTest.php @@ -0,0 +1,79 @@ + 'true', + 'DD_TRACE_GENERATE_ROOT_SPAN' => '1', + 'DD_TRACE_AUTO_FLUSH_ENABLED' => '1', + ]); + } + + public function testProcessTagsEnabledForWebSapi() + { + $traces = $this->tracesFromWebRequest(function () { + $spec = new RequestSpec( + __FUNCTION__, + 'GET', + '/simple', + [] + ); + return $this->call($spec); + }); + + $this->assertCount(1, $traces); + $rootSpan = $traces[0][0]; + + // Verify _dd.tags.process exists + $this->assertArrayHasKey('_dd.tags.process', $rootSpan['meta']); + $processTags = $rootSpan['meta']['_dd.tags.process']; + + // Parse the process tags + $tags = []; + foreach (explode(',', $processTags) as $pair) { + list($key, $value) = explode(':', $pair, 2); + $tags[$key] = $value; + } + + // Web SAPI should have server.type and entrypoint.workdir + $this->assertArrayHasKey('server.type', $tags, 'server.type should be present for web SAPI'); + $this->assertArrayHasKey('entrypoint.workdir', $tags, 'entrypoint.workdir should be present'); + + // Web SAPI should NOT have entrypoint.name, entrypoint.basedir, or entrypoint.type + $this->assertArrayNotHasKey('entrypoint.name', $tags, 'entrypoint.name should not be present for web SAPI'); + $this->assertArrayNotHasKey('entrypoint.basedir', $tags, 'entrypoint.basedir should not be present for web SAPI'); + $this->assertArrayNotHasKey('entrypoint.type', $tags, 'entrypoint.type should not be present for web SAPI'); + + // Verify server.type is one of the expected SAPIs tested in CI + $expectedSapis = ['cli-server', 'cgi-fcgi', 'apache2handler', 'fpm-fcgi']; + $this->assertContains( + $tags['server.type'], + $expectedSapis, + sprintf( + 'server.type should be one of [%s], got: %s', + implode(', ', $expectedSapis), + $tags['server.type'] + ) + ); + + // Verify server.type is normalized (lowercase, only allowed chars) + $this->assertRegularExpression( + '/^[a-z0-9\/_.-]+$/', + $tags['server.type'], + 'server.type should be normalized' + ); + } +} + diff --git a/tests/ext/process_tags_enabled.phpt b/tests/ext/process_tags_enabled.phpt new file mode 100644 index 0000000000..cda502ae5f --- /dev/null +++ b/tests/ext/process_tags_enabled.phpt @@ -0,0 +1,51 @@ +--TEST-- +Process tags are added to root span when enabled +--ENV-- +DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED=1 +DD_TRACE_GENERATE_ROOT_SPAN=0 +DD_TRACE_AUTO_FLUSH_ENABLED=0 +--FILE-- +name = 'root_span'; +$span->service = 'test_service'; +\DDTrace\close_span(); + +$spans = dd_trace_serialize_closed_spans(); + +// Check if process tags are present +if (isset($spans[0]['meta']['_dd.tags.process'])) { + $processTags = $spans[0]['meta']['_dd.tags.process']; + echo "Process tags present: YES\n"; + echo "Process tags: $processTags\n"; + + // Verify format: comma-separated key:value pairs + $tags = explode(',', $processTags); + + // Verify keys are sorted alphabetically + $keys = array_map(function($tag) { + return explode(':', $tag, 2)[0]; + }, $tags); + $sortedKeys = $keys; + sort($sortedKeys); + echo "Keys sorted: " . ($keys === $sortedKeys ? 'YES' : 'NO') . "\n"; + + // Verify all values are normalized (lowercase, a-z0-9/.-_ only) + $allNormalized = true; + foreach ($tags as $tag) { + $value = explode(':', $tag, 2)[1]; + if (!preg_match('/^[a-z0-9\/\.\-_]+$/', $value)) { + $allNormalized = false; + echo "Non-normalized value found: $value\n"; + } + } + echo "Values normalized: " . ($allNormalized ? 'YES' : 'NO') . "\n"; +} else { + echo "Process tags present: NO\n"; +} +?> +--EXPECTF-- +Process tags present: YES +Process tags: entrypoint.basedir:ext,entrypoint.name:process_tags_enabled.php,entrypoint.type:cli,entrypoint.workdir:%s +Keys sorted: YES +Values normalized: YES