From d76a11d8f0555e80bfb8b5eaa1096081d48252b4 Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Tue, 4 Nov 2025 15:20:38 +0100 Subject: [PATCH 01/27] chore(internal): add process_tags to first span of each payload --- ddtrace/_trace/processor/__init__.py | 4 ++ ddtrace/internal/constants.py | 1 + ddtrace/internal/process_tags/__init__.py | 76 ++++++++++++++++++++++ ddtrace/internal/process_tags/constants.py | 7 ++ ddtrace/settings/_config.py | 3 + 5 files changed, 91 insertions(+) create mode 100644 ddtrace/internal/process_tags/__init__.py create mode 100644 ddtrace/internal/process_tags/constants.py diff --git a/ddtrace/_trace/processor/__init__.py b/ddtrace/_trace/processor/__init__.py index 7b1fcec816e..43630c6329f 100644 --- a/ddtrace/_trace/processor/__init__.py +++ b/ddtrace/_trace/processor/__init__.py @@ -13,8 +13,10 @@ from ddtrace._trace.span import _get_64_highest_order_bits_as_hex from ddtrace.constants import _APM_ENABLED_METRIC_KEY as MK_APM_ENABLED from ddtrace.constants import _SINGLE_SPAN_SAMPLING_MECHANISM +from ddtrace.internal.constants import PROCESS_TAGS from ddtrace.internal import gitmetadata from ddtrace.internal import telemetry +from ddtrace.internal.process_tags import process_tags from ddtrace.internal.constants import COMPONENT from ddtrace.internal.constants import HIGHER_ORDER_TRACE_ID_BITS from ddtrace.internal.constants import LAST_DD_PARENT_ID_KEY @@ -250,6 +252,8 @@ def process_trace(self, trace: List[Span]) -> Optional[List[Span]]: span._update_tags_from_context() self._set_git_metadata(span) span._set_tag_str("language", "python") + if serialized_process_tags := process_tags.get_serialized_process_tags(): + span._set_tag_str(PROCESS_TAGS, serialized_process_tags) # for 128 bit trace ids if span.trace_id > MAX_UINT_64BITS: trace_id_hob = _get_64_highest_order_bits_as_hex(span.trace_id) diff --git a/ddtrace/internal/constants.py b/ddtrace/internal/constants.py index fc572d0ffaa..059439aa024 100644 --- a/ddtrace/internal/constants.py +++ b/ddtrace/internal/constants.py @@ -70,6 +70,7 @@ HTTP_REQUEST_PATH_PARAMETER = "http.request.path.parameter" REQUEST_PATH_PARAMS = "http.request.path_params" STATUS_403_TYPE_AUTO = {"status_code": 403, "type": "auto"} +PROCESS_TAGS = "_dd.tags.process" CONTAINER_ID_HEADER_NAME = "Datadog-Container-Id" diff --git a/ddtrace/internal/process_tags/__init__.py b/ddtrace/internal/process_tags/__init__.py new file mode 100644 index 00000000000..ea62414952f --- /dev/null +++ b/ddtrace/internal/process_tags/__init__.py @@ -0,0 +1,76 @@ +import os +from pathlib import Path +import re +import sys +from typing import Callable +from typing import Dict +from typing import Optional + +from ddtrace.internal.forksafe import Lock +from ddtrace.internal.logger import get_logger +from ddtrace.settings._config import config + +from .constants import ENTRYPOINT_BASEDIR_TAG +from .constants import ENTRYPOINT_NAME_TAG +from .constants import ENTRYPOINT_TYPE_SCRIPT +from .constants import ENTRYPOINT_TYPE_TAG +from .constants import ENTRYPOINT_WORKDIR_TAG + + +log = get_logger(__name__) + +# outside of ProcessTags class for test purpose +def normalize_tag(value: str) -> str: + return re.sub(r"[^a-z0-9/._-]", "_", value.lower()) +class ProcessTags: + process_tags: Dict[str, str] = {} + + def __init__(self) -> None: + self._lock = Lock() + self._serialized: Optional[str] = None + self._enabled = config._process_tags_enabled + self.reload() + + def _serialize_process_tags(self) -> Optional[str]: + if self.process_tags and not self._serialized: + serialized_tags = ",".join(f"{key}:{value}" for key, value in self.process_tags.items()) + return serialized_tags + return None + + def get_serialized_process_tags(self) -> Optional[str]: + if not self._enabled: + return None + + with self._lock: + if not self._serialized: + self._serialized = self._serialize_process_tags() + return self._serialized + + def add_process_tag(self, key: str, value: Optional[str] = None, compute: Optional[Callable[[], str]] = None): + if not self._enabled: + return + + if compute: + try: + value = compute() + except Exception as e: + log.debug("failed to set %s process_tag: %s", key, e) + + if value: + with self._lock: + self.process_tags[key] = normalize_tag(value) + self._serialized = None + + def reload(self): + if not self._enabled: + return + + with self._lock: + self.process_tags = {} + + self.add_process_tag(ENTRYPOINT_WORKDIR_TAG, compute=lambda: os.path.basename(os.getcwd())) + self.add_process_tag(ENTRYPOINT_BASEDIR_TAG, compute=lambda: Path(sys.argv[0]).resolve().parent.name) + self.add_process_tag(ENTRYPOINT_NAME_TAG, compute=lambda: os.path.splitext(os.path.basename(sys.argv[0]))[0]) + self.add_process_tag(ENTRYPOINT_TYPE_TAG, value=ENTRYPOINT_TYPE_SCRIPT) + +process_tags = ProcessTags() \ No newline at end of file diff --git a/ddtrace/internal/process_tags/constants.py b/ddtrace/internal/process_tags/constants.py new file mode 100644 index 00000000000..a863549cfb5 --- /dev/null +++ b/ddtrace/internal/process_tags/constants.py @@ -0,0 +1,7 @@ +ENTRYPOINT_NAME_TAG = "entrypoint.name" +ENTRYPOINT_WORKDIR_TAG = "entrypoint.workdir" + +ENTRYPOINT_TYPE_TAG = "entrypoint.type" +ENTRYPOINT_TYPE_SCRIPT = "script" + +ENTRYPOINT_BASEDIR_TAG = "entrypoint.basedir" diff --git a/ddtrace/settings/_config.py b/ddtrace/settings/_config.py index 78a33720203..6878d64f463 100644 --- a/ddtrace/settings/_config.py +++ b/ddtrace/settings/_config.py @@ -669,6 +669,9 @@ def __init__(self): self._trace_resource_renaming_always_simplified_endpoint = _get_config( "DD_TRACE_RESOURCE_RENAMING_ALWAYS_SIMPLIFIED_ENDPOINT", default=False, modifier=asbool ) + self._process_tags_enabled = _get_config( + "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED", default=False, modifier=asbool + ) # Long-running span interval configurations # Only supported for Ray spans for now From a3643d8a65b3380408b1dd49546d65e4dcde2971 Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Wed, 5 Nov 2025 15:34:53 +0100 Subject: [PATCH 02/27] tests(process_tags): add tests --- .riot/requirements/1645326.txt | 19 ++++ riotfile.py | 5 + tests/process_tags/__init__.py | 0 tests/process_tags/test_process_tags.py | 124 ++++++++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 .riot/requirements/1645326.txt create mode 100644 tests/process_tags/__init__.py create mode 100644 tests/process_tags/test_process_tags.py diff --git a/.riot/requirements/1645326.txt b/.riot/requirements/1645326.txt new file mode 100644 index 00000000000..9201e2642b1 --- /dev/null +++ b/.riot/requirements/1645326.txt @@ -0,0 +1,19 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile --allow-unsafe --no-annotate .riot/requirements/1645326.in +# +attrs==25.4.0 +coverage[toml]==7.11.0 +hypothesis==6.45.0 +iniconfig==2.3.0 +mock==5.2.0 +opentracing==2.4.0 +packaging==25.0 +pluggy==1.6.0 +pygments==2.19.2 +pytest==8.4.2 +pytest-cov==7.0.0 +pytest-mock==3.15.1 +sortedcontainers==2.4.0 diff --git a/riotfile.py b/riotfile.py index a30d01e1af6..10b3deab757 100644 --- a/riotfile.py +++ b/riotfile.py @@ -498,6 +498,11 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT command="pytest --no-cov {cmdargs} tests/coverage -s", pys=select_pys(max_version="3.12"), ), + Venv( + name="process_tags", + command="pytest -v {cmdargs} tests/process_tags/", + pys=select_pys(min_version="3.9") + ), Venv( name="internal", env={ diff --git a/tests/process_tags/__init__.py b/tests/process_tags/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/process_tags/test_process_tags.py b/tests/process_tags/test_process_tags.py new file mode 100644 index 00000000000..5096329c2da --- /dev/null +++ b/tests/process_tags/test_process_tags.py @@ -0,0 +1,124 @@ +from ddtrace.internal.process_tags import normalize_tag +from ddtrace.internal.process_tags import process_tags +from ddtrace.internal.constants import PROCESS_TAGS +from ddtrace.internal.process_tags.constants import ENTRYPOINT_BASEDIR_TAG, ENTRYPOINT_NAME_TAG, ENTRYPOINT_TYPE_SCRIPT, ENTRYPOINT_TYPE_TAG, ENTRYPOINT_WORKDIR_TAG +from tests.utils import TracerTestCase +from tests.utils import override_env +import sys +import os +from pathlib import Path + +def test_normalize_tag(): + assert normalize_tag("HelloWorld") == "helloworld" + assert normalize_tag("Hello@World!") == "hello_world_" + assert normalize_tag("HeLLo123") == "hello123" + assert normalize_tag("hello world") == "hello_world" + assert normalize_tag("a/b.c_d-e") == "a/b.c_d-e" + assert normalize_tag("héllø") == "h_ll_" + assert normalize_tag("") == "" + assert normalize_tag("💡⚡️") == "___" + assert normalize_tag("!foo@") == "_foo_" + assert normalize_tag("123_abc.DEF-ghi/jkl") == "123_abc.def-ghi/jkl" + assert normalize_tag("Env:Prod-Server#1") == "env_prod-server_1" + +class TestProcessTags(TracerTestCase): + + def test_process_tags_deactivated(self): + with self.tracer.trace("test"): + pass + + span = self.get_spans()[0] + assert span is not None + assert PROCESS_TAGS not in span._meta + + def test_process_tags_activated_with_override_env(self): + """Test process tags using override_env instead of run_in_subprocess""" + with override_env(dict(DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED="True")): + process_tags._enabled = True + process_tags.reload() + + with self.tracer.trace("test"): + pass + + process_tags._enabled = False + + span = self.get_spans()[0] + assert span is not None + assert PROCESS_TAGS in span._meta + + expected_name = "pytest" + expected_type = ENTRYPOINT_TYPE_SCRIPT + expected_basedir = Path(sys.argv[0]).resolve().parent.name + expected_workdir = os.path.basename(os.getcwd()) + + serialized_tags = span._meta[PROCESS_TAGS] + expected_raw = ( + f"{ENTRYPOINT_WORKDIR_TAG}:{expected_workdir}," + f"{ENTRYPOINT_BASEDIR_TAG}:{expected_basedir}," + f"{ENTRYPOINT_NAME_TAG}:{expected_name}," + f"{ENTRYPOINT_TYPE_TAG}:{expected_type}" + ) + assert serialized_tags == expected_raw + + tags_dict = dict(tag.split(":", 1) for tag in serialized_tags.split(",")) + assert tags_dict[ENTRYPOINT_NAME_TAG] == expected_name + assert tags_dict[ENTRYPOINT_TYPE_TAG] == expected_type + assert tags_dict[ENTRYPOINT_BASEDIR_TAG] == expected_basedir + assert tags_dict[ENTRYPOINT_WORKDIR_TAG] == expected_workdir + + def test_process_tags_only_on_local_root_span(self): + """Test that only local root spans get process tags, not children""" + with override_env(dict(DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED="True")): + process_tags._enabled = True + process_tags.reload() + + with self.tracer.trace("parent"): + with self.tracer.trace("child"): + pass + + process_tags._enabled = False + + spans = self.get_spans() + assert len(spans) == 2 + + parent = [s for s in spans if s.name == "parent"][0] + assert PROCESS_TAGS in parent._meta + + child = [s for s in spans if s.name == "child"][0] + assert PROCESS_TAGS not in child._meta + + def test_add_process_tag_compute_exception(self): + """Test error handling when compute raises exception""" + with override_env(dict(DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED="True")): + process_tags._enabled = True + process_tags.reload() + + def failing_compute(): + raise ValueError("Test exception") + + process_tags.add_process_tag("test.tag", compute=failing_compute) + + assert "test.tag" not in process_tags.process_tags + + process_tags.add_process_tag("test.working", value="value") + assert "test.working" in process_tags.process_tags + assert process_tags.process_tags["test.working"] == "value" + + process_tags._enabled = False + + def test_serialization_caching(self): + """Test that serialization is cached and invalidated properly""" + with override_env(dict(DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED="True")): + process_tags._enabled = True + process_tags.reload() + + result1 = process_tags.get_serialized_process_tags() + assert process_tags._serialized is not None + + process_tags.add_process_tag("custom.tag", value="test") + assert process_tags._serialized is None + + result3 = process_tags.get_serialized_process_tags() + assert "custom.tag:test" in result3 + + process_tags._enabled = False \ No newline at end of file From a52e8cc512e6ff4216b3443ff14ec4db4d989395 Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Wed, 5 Nov 2025 15:43:28 +0100 Subject: [PATCH 03/27] lint --- ddtrace/_trace/processor/__init__.py | 4 ++-- ddtrace/internal/process_tags/__init__.py | 17 ++++++++------ riotfile.py | 6 +---- tests/process_tags/test_process_tags.py | 28 ++++++++++++++--------- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/ddtrace/_trace/processor/__init__.py b/ddtrace/_trace/processor/__init__.py index 43630c6329f..fa2a2a74198 100644 --- a/ddtrace/_trace/processor/__init__.py +++ b/ddtrace/_trace/processor/__init__.py @@ -13,17 +13,17 @@ from ddtrace._trace.span import _get_64_highest_order_bits_as_hex from ddtrace.constants import _APM_ENABLED_METRIC_KEY as MK_APM_ENABLED from ddtrace.constants import _SINGLE_SPAN_SAMPLING_MECHANISM -from ddtrace.internal.constants import PROCESS_TAGS from ddtrace.internal import gitmetadata from ddtrace.internal import telemetry -from ddtrace.internal.process_tags import process_tags from ddtrace.internal.constants import COMPONENT from ddtrace.internal.constants import HIGHER_ORDER_TRACE_ID_BITS from ddtrace.internal.constants import LAST_DD_PARENT_ID_KEY from ddtrace.internal.constants import MAX_UINT_64BITS +from ddtrace.internal.constants import PROCESS_TAGS from ddtrace.internal.constants import SAMPLING_DECISION_TRACE_TAG_KEY from ddtrace.internal.constants import SamplingMechanism from ddtrace.internal.logger import get_logger +from ddtrace.internal.process_tags import process_tags from ddtrace.internal.rate_limiter import RateLimiter from ddtrace.internal.sampling import SpanSamplingRule from ddtrace.internal.sampling import get_span_sampling_rules diff --git a/ddtrace/internal/process_tags/__init__.py b/ddtrace/internal/process_tags/__init__.py index ea62414952f..0234ee2c861 100644 --- a/ddtrace/internal/process_tags/__init__.py +++ b/ddtrace/internal/process_tags/__init__.py @@ -19,21 +19,23 @@ log = get_logger(__name__) + # outside of ProcessTags class for test purpose def normalize_tag(value: str) -> str: return re.sub(r"[^a-z0-9/._-]", "_", value.lower()) -class ProcessTags: - process_tags: Dict[str, str] = {} + +class ProcessTags: def __init__(self) -> None: self._lock = Lock() self._serialized: Optional[str] = None self._enabled = config._process_tags_enabled + self._process_tags: Dict[str, str] = {} self.reload() def _serialize_process_tags(self) -> Optional[str]: - if self.process_tags and not self._serialized: - serialized_tags = ",".join(f"{key}:{value}" for key, value in self.process_tags.items()) + if self._process_tags and not self._serialized: + serialized_tags = ",".join(f"{key}:{value}" for key, value in self._process_tags.items()) return serialized_tags return None @@ -58,7 +60,7 @@ def add_process_tag(self, key: str, value: Optional[str] = None, compute: Option if value: with self._lock: - self.process_tags[key] = normalize_tag(value) + self._process_tags[key] = normalize_tag(value) self._serialized = None def reload(self): @@ -66,11 +68,12 @@ def reload(self): return with self._lock: - self.process_tags = {} + self._process_tags = {} self.add_process_tag(ENTRYPOINT_WORKDIR_TAG, compute=lambda: os.path.basename(os.getcwd())) self.add_process_tag(ENTRYPOINT_BASEDIR_TAG, compute=lambda: Path(sys.argv[0]).resolve().parent.name) self.add_process_tag(ENTRYPOINT_NAME_TAG, compute=lambda: os.path.splitext(os.path.basename(sys.argv[0]))[0]) self.add_process_tag(ENTRYPOINT_TYPE_TAG, value=ENTRYPOINT_TYPE_SCRIPT) -process_tags = ProcessTags() \ No newline at end of file + +process_tags = ProcessTags() diff --git a/riotfile.py b/riotfile.py index 10b3deab757..cb9a6a7e851 100644 --- a/riotfile.py +++ b/riotfile.py @@ -498,11 +498,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT command="pytest --no-cov {cmdargs} tests/coverage -s", pys=select_pys(max_version="3.12"), ), - Venv( - name="process_tags", - command="pytest -v {cmdargs} tests/process_tags/", - pys=select_pys(min_version="3.9") - ), + Venv(name="process_tags", command="pytest -v {cmdargs} tests/process_tags/", pys=select_pys(min_version="3.9")), Venv( name="internal", env={ diff --git a/tests/process_tags/test_process_tags.py b/tests/process_tags/test_process_tags.py index 5096329c2da..f4fc399ea9c 100644 --- a/tests/process_tags/test_process_tags.py +++ b/tests/process_tags/test_process_tags.py @@ -1,12 +1,18 @@ +import os +from pathlib import Path +import sys + +from ddtrace.internal.constants import PROCESS_TAGS from ddtrace.internal.process_tags import normalize_tag from ddtrace.internal.process_tags import process_tags -from ddtrace.internal.constants import PROCESS_TAGS -from ddtrace.internal.process_tags.constants import ENTRYPOINT_BASEDIR_TAG, ENTRYPOINT_NAME_TAG, ENTRYPOINT_TYPE_SCRIPT, ENTRYPOINT_TYPE_TAG, ENTRYPOINT_WORKDIR_TAG +from ddtrace.internal.process_tags.constants import ENTRYPOINT_BASEDIR_TAG +from ddtrace.internal.process_tags.constants import ENTRYPOINT_NAME_TAG +from ddtrace.internal.process_tags.constants import ENTRYPOINT_TYPE_SCRIPT +from ddtrace.internal.process_tags.constants import ENTRYPOINT_TYPE_TAG +from ddtrace.internal.process_tags.constants import ENTRYPOINT_WORKDIR_TAG from tests.utils import TracerTestCase from tests.utils import override_env -import sys -import os -from pathlib import Path + def test_normalize_tag(): assert normalize_tag("HelloWorld") == "helloworld" @@ -21,8 +27,8 @@ def test_normalize_tag(): assert normalize_tag("123_abc.DEF-ghi/jkl") == "123_abc.def-ghi/jkl" assert normalize_tag("Env:Prod-Server#1") == "env_prod-server_1" -class TestProcessTags(TracerTestCase): +class TestProcessTags(TracerTestCase): def test_process_tags_deactivated(self): with self.tracer.trace("test"): pass @@ -98,11 +104,11 @@ def failing_compute(): process_tags.add_process_tag("test.tag", compute=failing_compute) - assert "test.tag" not in process_tags.process_tags + assert "test.tag" not in process_tags._process_tags process_tags.add_process_tag("test.working", value="value") - assert "test.working" in process_tags.process_tags - assert process_tags.process_tags["test.working"] == "value" + assert "test.working" in process_tags._process_tags + assert process_tags._process_tags["test.working"] == "value" process_tags._enabled = False @@ -112,7 +118,7 @@ def test_serialization_caching(self): process_tags._enabled = True process_tags.reload() - result1 = process_tags.get_serialized_process_tags() + process_tags.get_serialized_process_tags() assert process_tags._serialized is not None process_tags.add_process_tag("custom.tag", value="test") @@ -121,4 +127,4 @@ def test_serialization_caching(self): result3 = process_tags.get_serialized_process_tags() assert "custom.tag:test" in result3 - process_tags._enabled = False \ No newline at end of file + process_tags._enabled = False From f943f2a8e60c174397afaa21c75b6081906912a2 Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Wed, 5 Nov 2025 15:55:03 +0100 Subject: [PATCH 04/27] fix: suitespec --- tests/suitespec.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/suitespec.yml b/tests/suitespec.yml index fe0ece52ca8..9f77a21711c 100644 --- a/tests/suitespec.yml +++ b/tests/suitespec.yml @@ -95,6 +95,9 @@ components: - ddtrace/internal/opentelemetry/* opentracer: - ddtrace/opentracer/* + process_tags: + - ddtrace/internal/process_tags/* + - tests/process_tags/* profiling: - ddtrace/profiling/* - ddtrace/internal/datadog/profiling/* @@ -242,6 +245,13 @@ suites: - '@core' runner: riot pattern: ^openfeature$ + process_tags: + parallelism: 1 + paths: + - '@process_tags' + - '@core' + - '@tracing' + runner: riot telemetry: parallelism: 1 paths: From 660bd640218ad93d8006fa2f51134e75802dd818 Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Wed, 5 Nov 2025 16:24:42 +0100 Subject: [PATCH 05/27] fix: telemetry test --- tests/telemetry/test_writer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/telemetry/test_writer.py b/tests/telemetry/test_writer.py index a02848dbac1..5b9cebf2107 100644 --- a/tests/telemetry/test_writer.py +++ b/tests/telemetry/test_writer.py @@ -395,6 +395,7 @@ def test_app_started_event_configuration_override(test_agent_session, run_python {"name": "DD_USER_MODEL_LOGIN_FIELD", "origin": "default", "value": ""}, {"name": "DD_USER_MODEL_NAME_FIELD", "origin": "default", "value": ""}, {"name": "DD_VERSION", "origin": "default", "value": None}, + {"name": "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED", "origin": "default", "value": False}, { "name": "OTEL_EXPORTER_OTLP_ENDPOINT", "origin": "env_var", From 78dd521fde47cd042a6481cbe976da279892d4b8 Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Wed, 5 Nov 2025 16:49:09 +0100 Subject: [PATCH 06/27] fix telemetry 2 --- tests/telemetry/test_writer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/telemetry/test_writer.py b/tests/telemetry/test_writer.py index 1347c66cbdd..362015f9e19 100644 --- a/tests/telemetry/test_writer.py +++ b/tests/telemetry/test_writer.py @@ -244,6 +244,7 @@ def test_app_started_event_configuration_override(test_agent_session, run_python {"name": "DD_ERROR_TRACKING_HANDLED_ERRORS_INCLUDE", "origin": "default", "value": ""}, {"name": "DD_EXCEPTION_REPLAY_CAPTURE_MAX_FRAMES", "origin": "default", "value": 8}, {"name": "DD_EXCEPTION_REPLAY_ENABLED", "origin": "env_var", "value": True}, + {"name": "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED", "origin": "default", "value": False}, {"name": "DD_FASTAPI_ASYNC_BODY_TIMEOUT_SECONDS", "origin": "default", "value": 0.1}, {"name": "DD_IAST_DEDUPLICATION_ENABLED", "origin": "default", "value": True}, {"name": "DD_IAST_ENABLED", "origin": "default", "value": False}, @@ -396,7 +397,6 @@ def test_app_started_event_configuration_override(test_agent_session, run_python {"name": "DD_USER_MODEL_LOGIN_FIELD", "origin": "default", "value": ""}, {"name": "DD_USER_MODEL_NAME_FIELD", "origin": "default", "value": ""}, {"name": "DD_VERSION", "origin": "default", "value": None}, - {"name": "DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED", "origin": "default", "value": False}, { "name": "OTEL_EXPORTER_OTLP_ENDPOINT", "origin": "env_var", From dd584901b5ca43f568cd079052a90e35011b579c Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Thu, 6 Nov 2025 11:28:30 +0100 Subject: [PATCH 07/27] simplify process_tags (brett review) --- ddtrace/_trace/processor/__init__.py | 4 +- ddtrace/internal/process_tags/__init__.py | 75 ++++----- tests/process_tags/test_process_tags.py | 144 +++++------------- ...ocessTags.test_process_tags_activated.json | 26 ++++ ..._process_tags_only_on_local_root_span.json | 38 +++++ 5 files changed, 129 insertions(+), 158 deletions(-) create mode 100644 tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_activated.json create mode 100644 tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json diff --git a/ddtrace/_trace/processor/__init__.py b/ddtrace/_trace/processor/__init__.py index fa2a2a74198..09614618227 100644 --- a/ddtrace/_trace/processor/__init__.py +++ b/ddtrace/_trace/processor/__init__.py @@ -252,8 +252,8 @@ def process_trace(self, trace: List[Span]) -> Optional[List[Span]]: span._update_tags_from_context() self._set_git_metadata(span) span._set_tag_str("language", "python") - if serialized_process_tags := process_tags.get_serialized_process_tags(): - span._set_tag_str(PROCESS_TAGS, serialized_process_tags) + if process_tags: + span._set_tag_str(PROCESS_TAGS, process_tags) # for 128 bit trace ids if span.trace_id > MAX_UINT_64BITS: trace_id_hob = _get_64_highest_order_bits_as_hex(span.trace_id) diff --git a/ddtrace/internal/process_tags/__init__.py b/ddtrace/internal/process_tags/__init__.py index 0234ee2c861..90b67fb4b18 100644 --- a/ddtrace/internal/process_tags/__init__.py +++ b/ddtrace/internal/process_tags/__init__.py @@ -2,11 +2,8 @@ from pathlib import Path import re import sys -from typing import Callable -from typing import Dict from typing import Optional -from ddtrace.internal.forksafe import Lock from ddtrace.internal.logger import get_logger from ddtrace.settings._config import config @@ -20,60 +17,42 @@ log = get_logger(__name__) -# outside of ProcessTags class for test purpose def normalize_tag(value: str) -> str: return re.sub(r"[^a-z0-9/._-]", "_", value.lower()) -class ProcessTags: - def __init__(self) -> None: - self._lock = Lock() - self._serialized: Optional[str] = None - self._enabled = config._process_tags_enabled - self._process_tags: Dict[str, str] = {} - self.reload() - - def _serialize_process_tags(self) -> Optional[str]: - if self._process_tags and not self._serialized: - serialized_tags = ",".join(f"{key}:{value}" for key, value in self._process_tags.items()) - return serialized_tags +def generate_process_tags() -> Optional[str]: + if not config._process_tags_enabled: return None - def get_serialized_process_tags(self) -> Optional[str]: - if not self._enabled: - return None - - with self._lock: - if not self._serialized: - self._serialized = self._serialize_process_tags() - return self._serialized - - def add_process_tag(self, key: str, value: Optional[str] = None, compute: Optional[Callable[[], str]] = None): - if not self._enabled: - return - - if compute: - try: - value = compute() - except Exception as e: - log.debug("failed to set %s process_tag: %s", key, e) + try: + return ",".join( + f"{key}:{normalize_tag(value)}" + for key, value in sorted( + [ + (ENTRYPOINT_WORKDIR_TAG, os.path.basename(os.getcwd())), + (ENTRYPOINT_BASEDIR_TAG, Path(sys.argv[0]).resolve().parent.name), + (ENTRYPOINT_NAME_TAG, os.path.splitext(os.path.basename(sys.argv[0]))[0]), + (ENTRYPOINT_TYPE_TAG, ENTRYPOINT_TYPE_SCRIPT), + ] + ) + ) + except Exception as e: + log.debug("failed to get process_tags: %s", e) + return None - if value: - with self._lock: - self._process_tags[key] = normalize_tag(value) - self._serialized = None - def reload(self): - if not self._enabled: - return +# For test purpose +def _process_tag_reload(): + global process_tags + process_tags = generate_process_tags() - with self._lock: - self._process_tags = {} + # Force update in the processor module for testing + import sys - self.add_process_tag(ENTRYPOINT_WORKDIR_TAG, compute=lambda: os.path.basename(os.getcwd())) - self.add_process_tag(ENTRYPOINT_BASEDIR_TAG, compute=lambda: Path(sys.argv[0]).resolve().parent.name) - self.add_process_tag(ENTRYPOINT_NAME_TAG, compute=lambda: os.path.splitext(os.path.basename(sys.argv[0]))[0]) - self.add_process_tag(ENTRYPOINT_TYPE_TAG, value=ENTRYPOINT_TYPE_SCRIPT) + if "ddtrace._trace.processor" in sys.modules: + processor_module = sys.modules["ddtrace._trace.processor"] + processor_module.process_tags = process_tags -process_tags = ProcessTags() +process_tags = generate_process_tags() diff --git a/tests/process_tags/test_process_tags.py b/tests/process_tags/test_process_tags.py index f4fc399ea9c..92d8beebfeb 100644 --- a/tests/process_tags/test_process_tags.py +++ b/tests/process_tags/test_process_tags.py @@ -1,35 +1,37 @@ -import os -from pathlib import Path -import sys +import pytest from ddtrace.internal.constants import PROCESS_TAGS +from ddtrace.internal.process_tags import _process_tag_reload from ddtrace.internal.process_tags import normalize_tag -from ddtrace.internal.process_tags import process_tags -from ddtrace.internal.process_tags.constants import ENTRYPOINT_BASEDIR_TAG -from ddtrace.internal.process_tags.constants import ENTRYPOINT_NAME_TAG -from ddtrace.internal.process_tags.constants import ENTRYPOINT_TYPE_SCRIPT -from ddtrace.internal.process_tags.constants import ENTRYPOINT_TYPE_TAG -from ddtrace.internal.process_tags.constants import ENTRYPOINT_WORKDIR_TAG +from ddtrace.settings._config import config from tests.utils import TracerTestCase -from tests.utils import override_env -def test_normalize_tag(): - assert normalize_tag("HelloWorld") == "helloworld" - assert normalize_tag("Hello@World!") == "hello_world_" - assert normalize_tag("HeLLo123") == "hello123" - assert normalize_tag("hello world") == "hello_world" - assert normalize_tag("a/b.c_d-e") == "a/b.c_d-e" - assert normalize_tag("héllø") == "h_ll_" - assert normalize_tag("") == "" - assert normalize_tag("💡⚡️") == "___" - assert normalize_tag("!foo@") == "_foo_" - assert normalize_tag("123_abc.DEF-ghi/jkl") == "123_abc.def-ghi/jkl" - assert normalize_tag("Env:Prod-Server#1") == "env_prod-server_1" +@pytest.mark.parametrize( + "input,expected", + [ + ("HelloWorld", "helloworld"), + ("Hello@World!", "hello_world_"), + ("HeLLo123", "hello123"), + ("hello world", "hello_world"), + ("a/b.c_d-e", "a/b.c_d-e"), + ("héllø", "h_ll_"), + ("", ""), + ("💡⚡️", "___"), + ("!foo@", "_foo_"), + ("123_abc.DEF-ghi/jkl", "123_abc.def-ghi/jkl"), + ("Env:Prod-Server#1", "env_prod-server_1"), + ], +) +def test_normalize_tag(input_tag, expected): + assert normalize_tag(input_tag) == expected class TestProcessTags(TracerTestCase): def test_process_tags_deactivated(self): + config._process_tags_enabled = False + _process_tag_reload() + with self.tracer.trace("test"): pass @@ -37,94 +39,20 @@ def test_process_tags_deactivated(self): assert span is not None assert PROCESS_TAGS not in span._meta - def test_process_tags_activated_with_override_env(self): + @pytest.mark.snapshot + def test_process_tags_activated(self): """Test process tags using override_env instead of run_in_subprocess""" - with override_env(dict(DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED="True")): - process_tags._enabled = True - process_tags.reload() - - with self.tracer.trace("test"): - pass - - process_tags._enabled = False - - span = self.get_spans()[0] - assert span is not None - assert PROCESS_TAGS in span._meta - - expected_name = "pytest" - expected_type = ENTRYPOINT_TYPE_SCRIPT - expected_basedir = Path(sys.argv[0]).resolve().parent.name - expected_workdir = os.path.basename(os.getcwd()) - - serialized_tags = span._meta[PROCESS_TAGS] - expected_raw = ( - f"{ENTRYPOINT_WORKDIR_TAG}:{expected_workdir}," - f"{ENTRYPOINT_BASEDIR_TAG}:{expected_basedir}," - f"{ENTRYPOINT_NAME_TAG}:{expected_name}," - f"{ENTRYPOINT_TYPE_TAG}:{expected_type}" - ) - assert serialized_tags == expected_raw + config._process_tags_enabled = True + _process_tag_reload() - tags_dict = dict(tag.split(":", 1) for tag in serialized_tags.split(",")) - assert tags_dict[ENTRYPOINT_NAME_TAG] == expected_name - assert tags_dict[ENTRYPOINT_TYPE_TAG] == expected_type - assert tags_dict[ENTRYPOINT_BASEDIR_TAG] == expected_basedir - assert tags_dict[ENTRYPOINT_WORKDIR_TAG] == expected_workdir + with self.tracer.trace("test"): + pass + @pytest.mark.snapshot def test_process_tags_only_on_local_root_span(self): """Test that only local root spans get process tags, not children""" - with override_env(dict(DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED="True")): - process_tags._enabled = True - process_tags.reload() - - with self.tracer.trace("parent"): - with self.tracer.trace("child"): - pass - - process_tags._enabled = False - - spans = self.get_spans() - assert len(spans) == 2 - - parent = [s for s in spans if s.name == "parent"][0] - assert PROCESS_TAGS in parent._meta - - child = [s for s in spans if s.name == "child"][0] - assert PROCESS_TAGS not in child._meta - - def test_add_process_tag_compute_exception(self): - """Test error handling when compute raises exception""" - with override_env(dict(DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED="True")): - process_tags._enabled = True - process_tags.reload() - - def failing_compute(): - raise ValueError("Test exception") - - process_tags.add_process_tag("test.tag", compute=failing_compute) - - assert "test.tag" not in process_tags._process_tags - - process_tags.add_process_tag("test.working", value="value") - assert "test.working" in process_tags._process_tags - assert process_tags._process_tags["test.working"] == "value" - - process_tags._enabled = False - - def test_serialization_caching(self): - """Test that serialization is cached and invalidated properly""" - with override_env(dict(DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED="True")): - process_tags._enabled = True - process_tags.reload() - - process_tags.get_serialized_process_tags() - assert process_tags._serialized is not None - - process_tags.add_process_tag("custom.tag", value="test") - assert process_tags._serialized is None - - result3 = process_tags.get_serialized_process_tags() - assert "custom.tag:test" in result3 - - process_tags._enabled = False + config._process_tags_enabled = True + _process_tag_reload() + with self.tracer.trace("parent"): + with self.tracer.trace("child"): + pass diff --git a/tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_activated.json b/tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_activated.json new file mode 100644 index 00000000000..f68a188592a --- /dev/null +++ b/tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_activated.json @@ -0,0 +1,26 @@ +[[ + { + "name": "test", + "service": "tests.process_tags", + "resource": "test", + "trace_id": 0, + "span_id": 1, + "parent_id": 0, + "type": "", + "error": 0, + "meta": { + "_dd.p.dm": "-0", + "_dd.p.tid": "690c776100000000", + "_dd.tags.process": "entrypoint.basedir:bin,entrypoint.name:pytest,entrypoint.type:script,entrypoint.workdir:project", + "language": "python", + "runtime-id": "cb0907fe4d334d8f952c546ae1bfe631" + }, + "metrics": { + "_dd.top_level": 1, + "_dd.tracer_kr": 1.0, + "_sampling_priority_v1": 1, + "process_id": 605 + }, + "duration": 122042, + "start": 1762424673740002008 + }]] diff --git a/tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json b/tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json new file mode 100644 index 00000000000..b38289fc80f --- /dev/null +++ b/tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json @@ -0,0 +1,38 @@ +[[ + { + "name": "parent", + "service": "tests.process_tags", + "resource": "parent", + "trace_id": 0, + "span_id": 1, + "parent_id": 0, + "type": "", + "error": 0, + "meta": { + "_dd.p.dm": "-0", + "_dd.p.tid": "690c77c500000000", + "_dd.tags.process": "entrypoint.basedir:bin,entrypoint.name:pytest,entrypoint.type:script,entrypoint.workdir:project", + "language": "python", + "runtime-id": "24077c4537e349bca8226bf23bbfac75" + }, + "metrics": { + "_dd.top_level": 1, + "_dd.tracer_kr": 1.0, + "_sampling_priority_v1": 1, + "process_id": 605 + }, + "duration": 125583, + "start": 1762424773059544096 + }, + { + "name": "child", + "service": "tests.process_tags", + "resource": "child", + "trace_id": 0, + "span_id": 2, + "parent_id": 1, + "type": "", + "error": 0, + "duration": 73458, + "start": 1762424773059582388 + }]] From 184ef53e54ec831bacbc7ab34b22d525075d5c0f Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Thu, 6 Nov 2025 11:30:18 +0100 Subject: [PATCH 08/27] update python version --- riotfile.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/riotfile.py b/riotfile.py index 729c1f61994..68208829248 100644 --- a/riotfile.py +++ b/riotfile.py @@ -521,7 +521,7 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT command="pytest --no-cov {cmdargs} tests/coverage -s", pys=select_pys(max_version="3.12"), ), - Venv(name="process_tags", command="pytest -v {cmdargs} tests/process_tags/", pys=select_pys(min_version="3.9")), + Venv(name="process_tags", command="pytest -v {cmdargs} tests/process_tags/", pys=select_pys()), Venv( name="internal", env={ From be2973ed279808067193e722f5a25ef2c5dd5a26 Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Thu, 6 Nov 2025 16:31:15 +0100 Subject: [PATCH 09/27] put tests within internal suite --- riotfile.py | 1 - .../test_process_tags.py | 2 +- tests/process_tags/__init__.py | 0 ...ProcessTags.test_process_tags_activated.json} | 10 +++++----- ...st_process_tags_only_on_local_root_span.json} | 16 ++++++++-------- tests/suitespec.yml | 11 +---------- 6 files changed, 15 insertions(+), 25 deletions(-) rename tests/{process_tags => internal}/test_process_tags.py (98%) delete mode 100644 tests/process_tags/__init__.py rename tests/snapshots/{tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_activated.json => tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json} (71%) rename tests/snapshots/{tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json => tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json} (69%) diff --git a/riotfile.py b/riotfile.py index 68208829248..d38850c428f 100644 --- a/riotfile.py +++ b/riotfile.py @@ -521,7 +521,6 @@ def select_pys(min_version: str = MIN_PYTHON_VERSION, max_version: str = MAX_PYT command="pytest --no-cov {cmdargs} tests/coverage -s", pys=select_pys(max_version="3.12"), ), - Venv(name="process_tags", command="pytest -v {cmdargs} tests/process_tags/", pys=select_pys()), Venv( name="internal", env={ diff --git a/tests/process_tags/test_process_tags.py b/tests/internal/test_process_tags.py similarity index 98% rename from tests/process_tags/test_process_tags.py rename to tests/internal/test_process_tags.py index 92d8beebfeb..bff40de30a5 100644 --- a/tests/process_tags/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -8,7 +8,7 @@ @pytest.mark.parametrize( - "input,expected", + "input_tag,expected", [ ("HelloWorld", "helloworld"), ("Hello@World!", "hello_world_"), diff --git a/tests/process_tags/__init__.py b/tests/process_tags/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_activated.json b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json similarity index 71% rename from tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_activated.json rename to tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json index f68a188592a..35cab17da03 100644 --- a/tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_activated.json +++ b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json @@ -1,7 +1,7 @@ [[ { "name": "test", - "service": "tests.process_tags", + "service": "tests.internal", "resource": "test", "trace_id": 0, "span_id": 1, @@ -10,10 +10,10 @@ "error": 0, "meta": { "_dd.p.dm": "-0", - "_dd.p.tid": "690c776100000000", + "_dd.p.tid": "690cbe5800000000", "_dd.tags.process": "entrypoint.basedir:bin,entrypoint.name:pytest,entrypoint.type:script,entrypoint.workdir:project", "language": "python", - "runtime-id": "cb0907fe4d334d8f952c546ae1bfe631" + "runtime-id": "dd59166f5fa34c50a0815ba3e3c2da93" }, "metrics": { "_dd.top_level": 1, @@ -21,6 +21,6 @@ "_sampling_priority_v1": 1, "process_id": 605 }, - "duration": 122042, - "start": 1762424673740002008 + "duration": 139041, + "start": 1762442840216120585 }]] diff --git a/tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json similarity index 69% rename from tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json rename to tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json index b38289fc80f..285e496b994 100644 --- a/tests/snapshots/tests.process_tags.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json +++ b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json @@ -1,7 +1,7 @@ [[ { "name": "parent", - "service": "tests.process_tags", + "service": "tests.internal", "resource": "parent", "trace_id": 0, "span_id": 1, @@ -10,10 +10,10 @@ "error": 0, "meta": { "_dd.p.dm": "-0", - "_dd.p.tid": "690c77c500000000", + "_dd.p.tid": "690cbe5800000000", "_dd.tags.process": "entrypoint.basedir:bin,entrypoint.name:pytest,entrypoint.type:script,entrypoint.workdir:project", "language": "python", - "runtime-id": "24077c4537e349bca8226bf23bbfac75" + "runtime-id": "dd59166f5fa34c50a0815ba3e3c2da93" }, "metrics": { "_dd.top_level": 1, @@ -21,18 +21,18 @@ "_sampling_priority_v1": 1, "process_id": 605 }, - "duration": 125583, - "start": 1762424773059544096 + "duration": 97791, + "start": 1762442840225996585 }, { "name": "child", - "service": "tests.process_tags", + "service": "tests.internal", "resource": "child", "trace_id": 0, "span_id": 2, "parent_id": 1, "type": "", "error": 0, - "duration": 73458, - "start": 1762424773059582388 + "duration": 51542, + "start": 1762442840226028043 }]] diff --git a/tests/suitespec.yml b/tests/suitespec.yml index cf45ec08778..f10de96a450 100644 --- a/tests/suitespec.yml +++ b/tests/suitespec.yml @@ -95,9 +95,6 @@ components: - ddtrace/internal/opentelemetry/* opentracer: - ddtrace/opentracer/* - process_tags: - - ddtrace/internal/process_tags/* - - tests/process_tags/* profiling: - ddtrace/profiling/* - ddtrace/internal/datadog/profiling/* @@ -211,6 +208,7 @@ suites: - tests/internal/* - tests/submod/* - tests/cache/* + - tests/snapshots/tests.internal.* runner: riot snapshot: true lib_injection: @@ -245,13 +243,6 @@ suites: - '@core' runner: riot pattern: ^openfeature$ - process_tags: - parallelism: 1 - paths: - - '@process_tags' - - '@core' - - '@tracing' - runner: riot telemetry: parallelism: 1 paths: From c6b4d7f037f9189c0341b86069e483626c862cbb Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Thu, 6 Nov 2025 17:23:39 +0100 Subject: [PATCH 10/27] remove sys hack --- ddtrace/_trace/processor/__init__.py | 6 +++--- ddtrace/internal/process_tags/__init__.py | 8 -------- tests/internal/test_process_tags.py | 2 -- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/ddtrace/_trace/processor/__init__.py b/ddtrace/_trace/processor/__init__.py index 09614618227..e651c3f39a8 100644 --- a/ddtrace/_trace/processor/__init__.py +++ b/ddtrace/_trace/processor/__init__.py @@ -23,7 +23,7 @@ from ddtrace.internal.constants import SAMPLING_DECISION_TRACE_TAG_KEY from ddtrace.internal.constants import SamplingMechanism from ddtrace.internal.logger import get_logger -from ddtrace.internal.process_tags import process_tags +from ddtrace.internal import process_tags from ddtrace.internal.rate_limiter import RateLimiter from ddtrace.internal.sampling import SpanSamplingRule from ddtrace.internal.sampling import get_span_sampling_rules @@ -252,8 +252,8 @@ def process_trace(self, trace: List[Span]) -> Optional[List[Span]]: span._update_tags_from_context() self._set_git_metadata(span) span._set_tag_str("language", "python") - if process_tags: - span._set_tag_str(PROCESS_TAGS, process_tags) + if p_tags := process_tags.process_tags: + span._set_tag_str(PROCESS_TAGS, p_tags) # for 128 bit trace ids if span.trace_id > MAX_UINT_64BITS: trace_id_hob = _get_64_highest_order_bits_as_hex(span.trace_id) diff --git a/ddtrace/internal/process_tags/__init__.py b/ddtrace/internal/process_tags/__init__.py index 90b67fb4b18..ccd64ad4b66 100644 --- a/ddtrace/internal/process_tags/__init__.py +++ b/ddtrace/internal/process_tags/__init__.py @@ -47,12 +47,4 @@ def _process_tag_reload(): global process_tags process_tags = generate_process_tags() - # Force update in the processor module for testing - import sys - - if "ddtrace._trace.processor" in sys.modules: - processor_module = sys.modules["ddtrace._trace.processor"] - processor_module.process_tags = process_tags - - process_tags = generate_process_tags() diff --git a/tests/internal/test_process_tags.py b/tests/internal/test_process_tags.py index bff40de30a5..c2a068be0a7 100644 --- a/tests/internal/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -41,7 +41,6 @@ def test_process_tags_deactivated(self): @pytest.mark.snapshot def test_process_tags_activated(self): - """Test process tags using override_env instead of run_in_subprocess""" config._process_tags_enabled = True _process_tag_reload() @@ -50,7 +49,6 @@ def test_process_tags_activated(self): @pytest.mark.snapshot def test_process_tags_only_on_local_root_span(self): - """Test that only local root spans get process tags, not children""" config._process_tags_enabled = True _process_tag_reload() with self.tracer.trace("parent"): From c6cb1be00d8ff0a1232c8cf78d3598f4402612bd Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Fri, 7 Nov 2025 11:21:59 +0100 Subject: [PATCH 11/27] make tests compatible with CI --- tests/internal/test_process_tags.py | 21 +++++++++++-------- ...ocessTags.test_process_tags_activated.json | 10 ++++----- ..._process_tags_only_on_local_root_span.json | 14 ++++++------- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/tests/internal/test_process_tags.py b/tests/internal/test_process_tags.py index c2a068be0a7..1d9a9026055 100644 --- a/tests/internal/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -5,6 +5,7 @@ from ddtrace.internal.process_tags import normalize_tag from ddtrace.settings._config import config from tests.utils import TracerTestCase +from unittest.mock import patch @pytest.mark.parametrize( @@ -41,16 +42,18 @@ def test_process_tags_deactivated(self): @pytest.mark.snapshot def test_process_tags_activated(self): - config._process_tags_enabled = True - _process_tag_reload() + with patch("sys.argv", ["/path/to/test_script.py"]), patch("os.getcwd", return_value="/path/to/workdir"): + config._process_tags_enabled = True + _process_tag_reload() - with self.tracer.trace("test"): - pass + with self.tracer.trace("test"): + pass @pytest.mark.snapshot def test_process_tags_only_on_local_root_span(self): - config._process_tags_enabled = True - _process_tag_reload() - with self.tracer.trace("parent"): - with self.tracer.trace("child"): - pass + with patch("sys.argv", ["/path/to/test_script.py"]), patch("os.getcwd", return_value="/path/to/workdir"): + config._process_tags_enabled = True + _process_tag_reload() + with self.tracer.trace("parent"): + with self.tracer.trace("child"): + pass diff --git a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json index 35cab17da03..32c551dba37 100644 --- a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json +++ b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json @@ -10,10 +10,10 @@ "error": 0, "meta": { "_dd.p.dm": "-0", - "_dd.p.tid": "690cbe5800000000", - "_dd.tags.process": "entrypoint.basedir:bin,entrypoint.name:pytest,entrypoint.type:script,entrypoint.workdir:project", + "_dd.p.tid": "690dc7bc00000000", + "_dd.tags.process": "entrypoint.basedir:to,entrypoint.name:test_script,entrypoint.type:script,entrypoint.workdir:workdir", "language": "python", - "runtime-id": "dd59166f5fa34c50a0815ba3e3c2da93" + "runtime-id": "97a0041576ed4e53a93eff9971a69fca" }, "metrics": { "_dd.top_level": 1, @@ -21,6 +21,6 @@ "_sampling_priority_v1": 1, "process_id": 605 }, - "duration": 139041, - "start": 1762442840216120585 + "duration": 94000, + "start": 1762510780675377553 }]] diff --git a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json index 285e496b994..bc1e4fa64f3 100644 --- a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json +++ b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json @@ -10,10 +10,10 @@ "error": 0, "meta": { "_dd.p.dm": "-0", - "_dd.p.tid": "690cbe5800000000", - "_dd.tags.process": "entrypoint.basedir:bin,entrypoint.name:pytest,entrypoint.type:script,entrypoint.workdir:project", + "_dd.p.tid": "690dc7bc00000000", + "_dd.tags.process": "entrypoint.basedir:to,entrypoint.name:test_script,entrypoint.type:script,entrypoint.workdir:workdir", "language": "python", - "runtime-id": "dd59166f5fa34c50a0815ba3e3c2da93" + "runtime-id": "97a0041576ed4e53a93eff9971a69fca" }, "metrics": { "_dd.top_level": 1, @@ -21,8 +21,8 @@ "_sampling_priority_v1": 1, "process_id": 605 }, - "duration": 97791, - "start": 1762442840225996585 + "duration": 107667, + "start": 1762510780688981511 }, { "name": "child", @@ -33,6 +33,6 @@ "parent_id": 1, "type": "", "error": 0, - "duration": 51542, - "start": 1762442840226028043 + "duration": 64625, + "start": 1762510780689007511 }]] From 74164666c5973ba40a0becef32b480e9a237977a Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Fri, 7 Nov 2025 11:37:41 +0100 Subject: [PATCH 12/27] lint --- ddtrace/_trace/processor/__init__.py | 2 +- ddtrace/internal/process_tags/__init__.py | 1 + tests/internal/test_process_tags.py | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/ddtrace/_trace/processor/__init__.py b/ddtrace/_trace/processor/__init__.py index e651c3f39a8..acc38b0b74d 100644 --- a/ddtrace/_trace/processor/__init__.py +++ b/ddtrace/_trace/processor/__init__.py @@ -14,6 +14,7 @@ from ddtrace.constants import _APM_ENABLED_METRIC_KEY as MK_APM_ENABLED from ddtrace.constants import _SINGLE_SPAN_SAMPLING_MECHANISM from ddtrace.internal import gitmetadata +from ddtrace.internal import process_tags from ddtrace.internal import telemetry from ddtrace.internal.constants import COMPONENT from ddtrace.internal.constants import HIGHER_ORDER_TRACE_ID_BITS @@ -23,7 +24,6 @@ from ddtrace.internal.constants import SAMPLING_DECISION_TRACE_TAG_KEY from ddtrace.internal.constants import SamplingMechanism from ddtrace.internal.logger import get_logger -from ddtrace.internal import process_tags from ddtrace.internal.rate_limiter import RateLimiter from ddtrace.internal.sampling import SpanSamplingRule from ddtrace.internal.sampling import get_span_sampling_rules diff --git a/ddtrace/internal/process_tags/__init__.py b/ddtrace/internal/process_tags/__init__.py index ccd64ad4b66..30b6d6296df 100644 --- a/ddtrace/internal/process_tags/__init__.py +++ b/ddtrace/internal/process_tags/__init__.py @@ -47,4 +47,5 @@ def _process_tag_reload(): global process_tags process_tags = generate_process_tags() + process_tags = generate_process_tags() diff --git a/tests/internal/test_process_tags.py b/tests/internal/test_process_tags.py index 1d9a9026055..901bfb0fe47 100644 --- a/tests/internal/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + import pytest from ddtrace.internal.constants import PROCESS_TAGS @@ -5,7 +7,6 @@ from ddtrace.internal.process_tags import normalize_tag from ddtrace.settings._config import config from tests.utils import TracerTestCase -from unittest.mock import patch @pytest.mark.parametrize( From 0428dcd8778e677f5118a6b7d11806a05a8af4fa Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Mon, 10 Nov 2025 13:56:27 +0100 Subject: [PATCH 13/27] brett review --- .riot/requirements/1645326.txt | 19 ------ tests/internal/test_process_tags.py | 60 +++++++++++++++--- ...s_tags.TestProcessTags.test_edge_case.json | 26 ++++++++ ...ocessTags.test_process_tags_activated.json | 26 +++++--- ...essTags.test_process_tags_deactivated.json | 25 ++++++++ ...ocessTags.test_process_tags_edge_case.json | 26 ++++++++ ...stProcessTags.test_process_tags_error.json | 25 ++++++++ ..._process_tags_only_on_local_root_span.json | 38 ------------ ...sTags.test_process_tags_partial_flush.json | 61 +++++++++++++++++++ 9 files changed, 232 insertions(+), 74 deletions(-) delete mode 100644 .riot/requirements/1645326.txt create mode 100644 tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_edge_case.json create mode 100644 tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_deactivated.json create mode 100644 tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_edge_case.json create mode 100644 tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_error.json delete mode 100644 tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json create mode 100644 tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_partial_flush.json diff --git a/.riot/requirements/1645326.txt b/.riot/requirements/1645326.txt deleted file mode 100644 index 9201e2642b1..00000000000 --- a/.riot/requirements/1645326.txt +++ /dev/null @@ -1,19 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.13 -# by the following command: -# -# pip-compile --allow-unsafe --no-annotate .riot/requirements/1645326.in -# -attrs==25.4.0 -coverage[toml]==7.11.0 -hypothesis==6.45.0 -iniconfig==2.3.0 -mock==5.2.0 -opentracing==2.4.0 -packaging==25.0 -pluggy==1.6.0 -pygments==2.19.2 -pytest==8.4.2 -pytest-cov==7.0.0 -pytest-mock==3.15.1 -sortedcontainers==2.4.0 diff --git a/tests/internal/test_process_tags.py b/tests/internal/test_process_tags.py index 901bfb0fe47..558372b9c20 100644 --- a/tests/internal/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -2,10 +2,11 @@ import pytest -from ddtrace.internal.constants import PROCESS_TAGS +from ddtrace.internal import process_tags from ddtrace.internal.process_tags import _process_tag_reload from ddtrace.internal.process_tags import normalize_tag from ddtrace.settings._config import config +from tests.subprocesstest import run_in_subprocess from tests.utils import TracerTestCase @@ -30,6 +31,17 @@ def test_normalize_tag(input_tag, expected): class TestProcessTags(TracerTestCase): + def setUp(self): + super(TestProcessTags, self).setUp() + self._original_process_tags_enabled = config._process_tags_enabled + self._original_process_tags = process_tags.process_tags + + def tearDown(self): + config._process_tags_enabled = self._original_process_tags_enabled + process_tags.process_tags = self._original_process_tags + super().tearDown() + + @pytest.mark.snapshot def test_process_tags_deactivated(self): config._process_tags_enabled = False _process_tag_reload() @@ -37,24 +49,52 @@ def test_process_tags_deactivated(self): with self.tracer.trace("test"): pass - span = self.get_spans()[0] - assert span is not None - assert PROCESS_TAGS not in span._meta - @pytest.mark.snapshot def test_process_tags_activated(self): with patch("sys.argv", ["/path/to/test_script.py"]), patch("os.getcwd", return_value="/path/to/workdir"): config._process_tags_enabled = True _process_tag_reload() - with self.tracer.trace("test"): + with self.tracer.trace("parent"): + with self.tracer.trace("child"): + pass + + @pytest.mark.snapshot + def test_process_tags_edge_case(self): + with patch("sys.argv", ["/test_script"]), patch("os.getcwd", return_value="/path/to/workdir"): + config._process_tags_enabled = True + _process_tag_reload() + + with self.tracer.trace("span"): pass @pytest.mark.snapshot - def test_process_tags_only_on_local_root_span(self): + def test_process_tags_error(self): + with patch("sys.argv", []), patch("os.getcwd", return_value="/path/to/workdir"): + config._process_tags_enabled = True + + with self.override_global_config(dict(_telemetry_enabled=False)): + with patch("ddtrace.internal.process_tags.log") as mock_log: + _process_tag_reload() + + with self.tracer.trace("span"): + pass + + # Check if debug log was called + mock_log.debug.assert_called_once() + call_args = mock_log.debug.call_args[0] + assert "failed to get process_tags" in call_args[0] + + @pytest.mark.snapshot + @run_in_subprocess(env_overrides=dict(DD_TRACE_PARTIAL_FLUSH_ENABLED="true", DD_TRACE_PARTIAL_FLUSH_MIN_SPANS="2")) + def test_process_tags_partial_flush(self): with patch("sys.argv", ["/path/to/test_script.py"]), patch("os.getcwd", return_value="/path/to/workdir"): config._process_tags_enabled = True _process_tag_reload() - with self.tracer.trace("parent"): - with self.tracer.trace("child"): - pass + + with self.override_global_config(dict(_partial_flush_enabled=True, _partial_flush_min_spans=2)): + with self.tracer.trace("parent"): + with self.tracer.trace("child1"): + pass + with self.tracer.trace("child2"): + pass diff --git a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_edge_case.json b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_edge_case.json new file mode 100644 index 00000000000..634d8e00dbd --- /dev/null +++ b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_edge_case.json @@ -0,0 +1,26 @@ +[[ + { + "name": "span", + "service": "tests.internal", + "resource": "span", + "trace_id": 0, + "span_id": 1, + "parent_id": 0, + "type": "", + "error": 0, + "meta": { + "_dd.p.dm": "-0", + "_dd.p.tid": "6911da3a00000000", + "_dd.tags.process": "entrypoint.basedir:,entrypoint.name:test_script,entrypoint.type:script,entrypoint.workdir:workdir", + "language": "python", + "runtime-id": "c9342b8003de45feb0bf56d32ece46a1" + }, + "metrics": { + "_dd.top_level": 1, + "_dd.tracer_kr": 1.0, + "_sampling_priority_v1": 1, + "process_id": 605 + }, + "duration": 105292, + "start": 1762777658431833668 + }]] diff --git a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json index 32c551dba37..8a3c1c18440 100644 --- a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json +++ b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_activated.json @@ -1,8 +1,8 @@ [[ { - "name": "test", + "name": "parent", "service": "tests.internal", - "resource": "test", + "resource": "parent", "trace_id": 0, "span_id": 1, "parent_id": 0, @@ -10,10 +10,10 @@ "error": 0, "meta": { "_dd.p.dm": "-0", - "_dd.p.tid": "690dc7bc00000000", + "_dd.p.tid": "6911dc5a00000000", "_dd.tags.process": "entrypoint.basedir:to,entrypoint.name:test_script,entrypoint.type:script,entrypoint.workdir:workdir", "language": "python", - "runtime-id": "97a0041576ed4e53a93eff9971a69fca" + "runtime-id": "2d5de91f8dd9442cad7faca5554a09f1" }, "metrics": { "_dd.top_level": 1, @@ -21,6 +21,18 @@ "_sampling_priority_v1": 1, "process_id": 605 }, - "duration": 94000, - "start": 1762510780675377553 - }]] + "duration": 231542, + "start": 1762778202287875128 + }, + { + "name": "child", + "service": "tests.internal", + "resource": "child", + "trace_id": 0, + "span_id": 2, + "parent_id": 1, + "type": "", + "error": 0, + "duration": 55500, + "start": 1762778202287999128 + }]] diff --git a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_deactivated.json b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_deactivated.json new file mode 100644 index 00000000000..5de189e565f --- /dev/null +++ b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_deactivated.json @@ -0,0 +1,25 @@ +[[ + { + "name": "test", + "service": "tests.internal", + "resource": "test", + "trace_id": 0, + "span_id": 1, + "parent_id": 0, + "type": "", + "error": 0, + "meta": { + "_dd.p.dm": "-0", + "_dd.p.tid": "6911dc5a00000000", + "language": "python", + "runtime-id": "2d5de91f8dd9442cad7faca5554a09f1" + }, + "metrics": { + "_dd.top_level": 1, + "_dd.tracer_kr": 1.0, + "_sampling_priority_v1": 1, + "process_id": 605 + }, + "duration": 22292, + "start": 1762778202327669586 + }]] diff --git a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_edge_case.json b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_edge_case.json new file mode 100644 index 00000000000..eb8e7ddaa8b --- /dev/null +++ b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_edge_case.json @@ -0,0 +1,26 @@ +[[ + { + "name": "span", + "service": "tests.internal", + "resource": "span", + "trace_id": 0, + "span_id": 1, + "parent_id": 0, + "type": "", + "error": 0, + "meta": { + "_dd.p.dm": "-0", + "_dd.p.tid": "6911dc5a00000000", + "_dd.tags.process": "entrypoint.basedir:,entrypoint.name:test_script,entrypoint.type:script,entrypoint.workdir:workdir", + "language": "python", + "runtime-id": "2d5de91f8dd9442cad7faca5554a09f1" + }, + "metrics": { + "_dd.top_level": 1, + "_dd.tracer_kr": 1.0, + "_sampling_priority_v1": 1, + "process_id": 605 + }, + "duration": 35458, + "start": 1762778202321224878 + }]] diff --git a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_error.json b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_error.json new file mode 100644 index 00000000000..4af59e2e651 --- /dev/null +++ b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_error.json @@ -0,0 +1,25 @@ +[[ + { + "name": "span", + "service": "tests.internal", + "resource": "span", + "trace_id": 0, + "span_id": 1, + "parent_id": 0, + "type": "", + "error": 0, + "meta": { + "_dd.p.dm": "-0", + "_dd.p.tid": "6911db6e00000000", + "language": "python", + "runtime-id": "c59cb90aad3246579bc4421d1cca07c8" + }, + "metrics": { + "_dd.top_level": 1, + "_dd.tracer_kr": 1.0, + "_sampling_priority_v1": 1, + "process_id": 605 + }, + "duration": 102833, + "start": 1762777966446950922 + }]] diff --git a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json deleted file mode 100644 index bc1e4fa64f3..00000000000 --- a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_only_on_local_root_span.json +++ /dev/null @@ -1,38 +0,0 @@ -[[ - { - "name": "parent", - "service": "tests.internal", - "resource": "parent", - "trace_id": 0, - "span_id": 1, - "parent_id": 0, - "type": "", - "error": 0, - "meta": { - "_dd.p.dm": "-0", - "_dd.p.tid": "690dc7bc00000000", - "_dd.tags.process": "entrypoint.basedir:to,entrypoint.name:test_script,entrypoint.type:script,entrypoint.workdir:workdir", - "language": "python", - "runtime-id": "97a0041576ed4e53a93eff9971a69fca" - }, - "metrics": { - "_dd.top_level": 1, - "_dd.tracer_kr": 1.0, - "_sampling_priority_v1": 1, - "process_id": 605 - }, - "duration": 107667, - "start": 1762510780688981511 - }, - { - "name": "child", - "service": "tests.internal", - "resource": "child", - "trace_id": 0, - "span_id": 2, - "parent_id": 1, - "type": "", - "error": 0, - "duration": 64625, - "start": 1762510780689007511 - }]] diff --git a/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_partial_flush.json b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_partial_flush.json new file mode 100644 index 00000000000..ef399836ce6 --- /dev/null +++ b/tests/snapshots/tests.internal.test_process_tags.TestProcessTags.test_process_tags_partial_flush.json @@ -0,0 +1,61 @@ +[[ + { + "name": "parent", + "service": "", + "resource": "parent", + "trace_id": 0, + "span_id": 1, + "parent_id": 0, + "type": "", + "error": 0, + "meta": { + "_dd.p.dm": "-0", + "_dd.p.tid": "6911e0a800000000", + "_dd.tags.process": "entrypoint.basedir:to,entrypoint.name:test_script,entrypoint.type:script,entrypoint.workdir:workdir", + "language": "python", + "runtime-id": "9e17705896a74bc5ba68a054ff29e42e" + }, + "metrics": { + "_dd.top_level": 1, + "_dd.tracer_kr": 1.0, + "_sampling_priority_v1": 1, + "process_id": 629 + }, + "duration": 308417, + "start": 1762779304752393180 + }, + { + "name": "child1", + "service": "", + "resource": "child1", + "trace_id": 0, + "span_id": 2, + "parent_id": 1, + "type": "", + "error": 0, + "meta": { + "_dd.p.dm": "-0", + "_dd.p.tid": "6911e0a800000000", + "_dd.tags.process": "entrypoint.basedir:to,entrypoint.name:test_script,entrypoint.type:script,entrypoint.workdir:workdir", + "language": "python" + }, + "metrics": { + "_dd.py.partial_flush": 2, + "_dd.tracer_kr": 1.0, + "_sampling_priority_v1": 1 + }, + "duration": 11500, + "start": 1762779304752417264 + }, + { + "name": "child2", + "service": "", + "resource": "child2", + "trace_id": 0, + "span_id": 3, + "parent_id": 1, + "type": "", + "error": 0, + "duration": 8167, + "start": 1762779304752455930 + }]] From 32ddf3530a777d4d52e036d2fd5c0a72ac0925fa Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Fri, 14 Nov 2025 13:44:21 +0100 Subject: [PATCH 14/27] improve tag normalization --- ddtrace/internal/process_tags/__init__.py | 7 ++++++- tests/internal/test_process_tags.py | 14 ++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/ddtrace/internal/process_tags/__init__.py b/ddtrace/internal/process_tags/__init__.py index 30b6d6296df..56e7aa6e092 100644 --- a/ddtrace/internal/process_tags/__init__.py +++ b/ddtrace/internal/process_tags/__init__.py @@ -16,9 +16,14 @@ log = get_logger(__name__) +_INVALID_CHARS_PATTERN = re.compile(r"[^a-z0-9/._-]") +_CONSECUTIVE_UNDERSCORES_PATTERN = re.compile(r"_{2,}") + def normalize_tag(value: str) -> str: - return re.sub(r"[^a-z0-9/._-]", "_", value.lower()) + normalized = _INVALID_CHARS_PATTERN.sub("_", value.lower()) + normalized = _CONSECUTIVE_UNDERSCORES_PATTERN.sub("_", normalized) + return normalized.strip("_") def generate_process_tags() -> Optional[str]: diff --git a/tests/internal/test_process_tags.py b/tests/internal/test_process_tags.py index 558372b9c20..d670a4b7c94 100644 --- a/tests/internal/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -14,16 +14,22 @@ "input_tag,expected", [ ("HelloWorld", "helloworld"), - ("Hello@World!", "hello_world_"), + ("Hello@World!", "hello_world"), ("HeLLo123", "hello123"), ("hello world", "hello_world"), ("a/b.c_d-e", "a/b.c_d-e"), - ("héllø", "h_ll_"), + ("héllø", "h_ll"), ("", ""), - ("💡⚡️", "___"), - ("!foo@", "_foo_"), + ("💡⚡️", ""), + ("!foo@", "foo"), ("123_abc.DEF-ghi/jkl", "123_abc.def-ghi/jkl"), ("Env:Prod-Server#1", "env_prod-server_1"), + ("__hello__world__", "hello_world"), + ("___test___", "test"), + ("_leading", "leading"), + ("trailing_", "trailing"), + ("double__underscore", "double_underscore"), + ("test\x99\x8faaa", "test_aaa"), ], ) def test_normalize_tag(input_tag, expected): From 7cf4143772a198fb3d0ac2f409191fdb98e2069a Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Mon, 17 Nov 2025 11:33:31 +0100 Subject: [PATCH 15/27] gab review --- ddtrace/internal/process_tags/__init__.py | 20 +++++++------------- ddtrace/internal/process_tags/constants.py | 7 ------- tests/internal/test_process_tags.py | 14 +++++++------- tests/utils.py | 5 +++++ 4 files changed, 19 insertions(+), 27 deletions(-) delete mode 100644 ddtrace/internal/process_tags/constants.py diff --git a/ddtrace/internal/process_tags/__init__.py b/ddtrace/internal/process_tags/__init__.py index 56e7aa6e092..bcb2e976ea5 100644 --- a/ddtrace/internal/process_tags/__init__.py +++ b/ddtrace/internal/process_tags/__init__.py @@ -5,17 +5,17 @@ from typing import Optional from ddtrace.internal.logger import get_logger -from ddtrace.settings._config import config - -from .constants import ENTRYPOINT_BASEDIR_TAG -from .constants import ENTRYPOINT_NAME_TAG -from .constants import ENTRYPOINT_TYPE_SCRIPT -from .constants import ENTRYPOINT_TYPE_TAG -from .constants import ENTRYPOINT_WORKDIR_TAG +from ddtrace.internal.settings._config import config log = get_logger(__name__) +ENTRYPOINT_NAME_TAG = "entrypoint.name" +ENTRYPOINT_WORKDIR_TAG = "entrypoint.workdir" +ENTRYPOINT_TYPE_TAG = "entrypoint.type" +ENTRYPOINT_TYPE_SCRIPT = "script" +ENTRYPOINT_BASEDIR_TAG = "entrypoint.basedir" + _INVALID_CHARS_PATTERN = re.compile(r"[^a-z0-9/._-]") _CONSECUTIVE_UNDERSCORES_PATTERN = re.compile(r"_{2,}") @@ -47,10 +47,4 @@ def generate_process_tags() -> Optional[str]: return None -# For test purpose -def _process_tag_reload(): - global process_tags - process_tags = generate_process_tags() - - process_tags = generate_process_tags() diff --git a/ddtrace/internal/process_tags/constants.py b/ddtrace/internal/process_tags/constants.py deleted file mode 100644 index a863549cfb5..00000000000 --- a/ddtrace/internal/process_tags/constants.py +++ /dev/null @@ -1,7 +0,0 @@ -ENTRYPOINT_NAME_TAG = "entrypoint.name" -ENTRYPOINT_WORKDIR_TAG = "entrypoint.workdir" - -ENTRYPOINT_TYPE_TAG = "entrypoint.type" -ENTRYPOINT_TYPE_SCRIPT = "script" - -ENTRYPOINT_BASEDIR_TAG = "entrypoint.basedir" diff --git a/tests/internal/test_process_tags.py b/tests/internal/test_process_tags.py index d670a4b7c94..624e7707589 100644 --- a/tests/internal/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -3,11 +3,11 @@ import pytest from ddtrace.internal import process_tags -from ddtrace.internal.process_tags import _process_tag_reload from ddtrace.internal.process_tags import normalize_tag -from ddtrace.settings._config import config +from ddtrace.internal.settings._config import config from tests.subprocesstest import run_in_subprocess from tests.utils import TracerTestCase +from tests.utils import process_tag_reload @pytest.mark.parametrize( @@ -50,7 +50,7 @@ def tearDown(self): @pytest.mark.snapshot def test_process_tags_deactivated(self): config._process_tags_enabled = False - _process_tag_reload() + process_tag_reload() with self.tracer.trace("test"): pass @@ -59,7 +59,7 @@ def test_process_tags_deactivated(self): def test_process_tags_activated(self): with patch("sys.argv", ["/path/to/test_script.py"]), patch("os.getcwd", return_value="/path/to/workdir"): config._process_tags_enabled = True - _process_tag_reload() + process_tag_reload() with self.tracer.trace("parent"): with self.tracer.trace("child"): @@ -69,7 +69,7 @@ def test_process_tags_activated(self): def test_process_tags_edge_case(self): with patch("sys.argv", ["/test_script"]), patch("os.getcwd", return_value="/path/to/workdir"): config._process_tags_enabled = True - _process_tag_reload() + process_tag_reload() with self.tracer.trace("span"): pass @@ -81,7 +81,7 @@ def test_process_tags_error(self): with self.override_global_config(dict(_telemetry_enabled=False)): with patch("ddtrace.internal.process_tags.log") as mock_log: - _process_tag_reload() + process_tag_reload() with self.tracer.trace("span"): pass @@ -96,7 +96,7 @@ def test_process_tags_error(self): def test_process_tags_partial_flush(self): with patch("sys.argv", ["/path/to/test_script.py"]), patch("os.getcwd", return_value="/path/to/workdir"): config._process_tags_enabled = True - _process_tag_reload() + process_tag_reload() with self.override_global_config(dict(_partial_flush_enabled=True, _partial_flush_min_spans=2)): with self.tracer.trace("parent"): diff --git a/tests/utils.py b/tests/utils.py index 70193d61293..cfb235bb79f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -29,6 +29,7 @@ from ddtrace.constants import _SPAN_MEASURED_KEY from ddtrace.ext import http from ddtrace.internal import core +from ddtrace.internal import process_tags from ddtrace.internal.ci_visibility.writer import CIVisibilityWriter from ddtrace.internal.constants import HIGHER_ORDER_TRACE_ID_BITS from ddtrace.internal.encoding import JSONEncoder @@ -1615,3 +1616,7 @@ def override_third_party_packages(packages: List[str]): filename_to_package.cache_clear() is_third_party.cache_clear() + + +def process_tag_reload(): + process_tags.process_tags = process_tags.generate_process_tags() From 67a7d46f07967209654bb7dc596c7ca392d5c798 Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Thu, 6 Nov 2025 16:10:12 +0100 Subject: [PATCH 16/27] chore(telemetry): add process tags --- ddtrace/internal/telemetry/data.py | 4 ++++ tests/telemetry/test_data.py | 31 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/ddtrace/internal/telemetry/data.py b/ddtrace/internal/telemetry/data.py index 7d3fa4ede91..4b07ad1ac52 100644 --- a/ddtrace/internal/telemetry/data.py +++ b/ddtrace/internal/telemetry/data.py @@ -56,6 +56,9 @@ def _get_application(key): This helper packs and unpacks get_application arguments to support caching. Cached() annotation only supports functions with one argument """ + # avoid circular dependency + from ddtrace.internal.process_tags import process_tags + service, version, env = key return { @@ -67,6 +70,7 @@ def _get_application(key): "tracer_version": get_version(), "runtime_name": platform.python_implementation(), "runtime_version": _format_version_info(sys.implementation.version), + "process_tags": process_tags, } diff --git a/tests/telemetry/test_data.py b/tests/telemetry/test_data.py index acd7daf33ba..ebe09735d5d 100644 --- a/tests/telemetry/test_data.py +++ b/tests/telemetry/test_data.py @@ -6,6 +6,7 @@ import ddtrace from ddtrace.internal.constants import DEFAULT_SERVICE_NAME +from ddtrace.internal.process_tags import _process_tag_reload from ddtrace.internal.runtime.container import CGroupInfo from ddtrace.internal.telemetry.data import _format_version_info from ddtrace.internal.telemetry.data import _get_container_id @@ -43,6 +44,36 @@ def test_get_application_with_values(): assert application["env"] == "staging" +def test_get_application_with_process_tags(): + from ddtrace.internal.process_tags.constants import ENTRYPOINT_BASEDIR_TAG + from ddtrace.internal.process_tags.constants import ENTRYPOINT_NAME_TAG + from ddtrace.internal.process_tags.constants import ENTRYPOINT_TYPE_SCRIPT + from ddtrace.internal.process_tags.constants import ENTRYPOINT_TYPE_TAG + from ddtrace.internal.process_tags.constants import ENTRYPOINT_WORKDIR_TAG + from ddtrace.settings._config import config + + with mock.patch("sys.argv", ["/path/to/test_script.py"]), mock.patch("os.getcwd", return_value="/path/to/workdir"): + try: + config._process_tags_enabled = True + _process_tag_reload() + + application = get_application("", "", "") + assert "process_tags" in application + + process_tags = application["process_tags"] + + expected_raw = ( + f"{ENTRYPOINT_BASEDIR_TAG}:to," + f"{ENTRYPOINT_NAME_TAG}:test_script," + f"{ENTRYPOINT_TYPE_TAG}:{ENTRYPOINT_TYPE_SCRIPT}," + f"{ENTRYPOINT_WORKDIR_TAG}:workdir" + ) + assert process_tags == expected_raw + finally: + config._process_tags_enabled = False + _process_tag_reload() + + def test_application_with_setenv(run_python_code_in_subprocess, monkeypatch): """ validates the return value of get_application when DD_SERVICE, DD_VERSION, and DD_ENV environment variables are set From a238b271141ac8fcb921677bd870ca78840d50b1 Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Wed, 12 Nov 2025 16:43:02 +0100 Subject: [PATCH 17/27] first attempt for fix --- ddtrace/internal/telemetry/data.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ddtrace/internal/telemetry/data.py b/ddtrace/internal/telemetry/data.py index 4b07ad1ac52..2e19f6a90d3 100644 --- a/ddtrace/internal/telemetry/data.py +++ b/ddtrace/internal/telemetry/data.py @@ -57,11 +57,11 @@ def _get_application(key): Cached() annotation only supports functions with one argument """ # avoid circular dependency - from ddtrace.internal.process_tags import process_tags + from ddtrace.internal import process_tags service, version, env = key - return { + application = { "service_name": service or DEFAULT_SERVICE_NAME, # mandatory field, can not be empty "service_version": version or "", "env": env or "", @@ -70,9 +70,13 @@ def _get_application(key): "tracer_version": get_version(), "runtime_name": platform.python_implementation(), "runtime_version": _format_version_info(sys.implementation.version), - "process_tags": process_tags, } + if process_tags := process_tags.process_tags: + application["process_tags"] = process_tags + + return application + def update_imported_dependencies(already_imported: Dict[str, str], new_modules: Iterable[str]) -> List[Dict[str, str]]: deps = [] From 9a4deb22a2b40314d97f307ef423c92545338e1a Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Wed, 12 Nov 2025 16:47:50 +0100 Subject: [PATCH 18/27] just checking the value is here --- tests/telemetry/test_data.py | 40 +++++++++++------------------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/tests/telemetry/test_data.py b/tests/telemetry/test_data.py index ebe09735d5d..dc7403d38e9 100644 --- a/tests/telemetry/test_data.py +++ b/tests/telemetry/test_data.py @@ -6,7 +6,6 @@ import ddtrace from ddtrace.internal.constants import DEFAULT_SERVICE_NAME -from ddtrace.internal.process_tags import _process_tag_reload from ddtrace.internal.runtime.container import CGroupInfo from ddtrace.internal.telemetry.data import _format_version_info from ddtrace.internal.telemetry.data import _get_container_id @@ -14,6 +13,7 @@ from ddtrace.internal.telemetry.data import get_application from ddtrace.internal.telemetry.data import get_host_info from ddtrace.internal.telemetry.data import get_hostname +from tests.utils import process_tag_reload def test_get_application(): @@ -45,33 +45,17 @@ def test_get_application_with_values(): def test_get_application_with_process_tags(): - from ddtrace.internal.process_tags.constants import ENTRYPOINT_BASEDIR_TAG - from ddtrace.internal.process_tags.constants import ENTRYPOINT_NAME_TAG - from ddtrace.internal.process_tags.constants import ENTRYPOINT_TYPE_SCRIPT - from ddtrace.internal.process_tags.constants import ENTRYPOINT_TYPE_TAG - from ddtrace.internal.process_tags.constants import ENTRYPOINT_WORKDIR_TAG - from ddtrace.settings._config import config - - with mock.patch("sys.argv", ["/path/to/test_script.py"]), mock.patch("os.getcwd", return_value="/path/to/workdir"): - try: - config._process_tags_enabled = True - _process_tag_reload() - - application = get_application("", "", "") - assert "process_tags" in application - - process_tags = application["process_tags"] - - expected_raw = ( - f"{ENTRYPOINT_BASEDIR_TAG}:to," - f"{ENTRYPOINT_NAME_TAG}:test_script," - f"{ENTRYPOINT_TYPE_TAG}:{ENTRYPOINT_TYPE_SCRIPT}," - f"{ENTRYPOINT_WORKDIR_TAG}:workdir" - ) - assert process_tags == expected_raw - finally: - config._process_tags_enabled = False - _process_tag_reload() + from ddtrace.internal.settings._config import config + + try: + config._process_tags_enabled = True + process_tag_reload() + + application = get_application("", "", "") + assert "process_tags" in application + finally: + config._process_tags_enabled = False + process_tag_reload() def test_application_with_setenv(run_python_code_in_subprocess, monkeypatch): From 813fe51f663f0fbb7437a6f99ce0186bcf18f27e Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Mon, 17 Nov 2025 15:19:44 +0100 Subject: [PATCH 19/27] typing --- ddtrace/internal/telemetry/data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ddtrace/internal/telemetry/data.py b/ddtrace/internal/telemetry/data.py index 2e19f6a90d3..628f5d43507 100644 --- a/ddtrace/internal/telemetry/data.py +++ b/ddtrace/internal/telemetry/data.py @@ -72,8 +72,8 @@ def _get_application(key): "runtime_version": _format_version_info(sys.implementation.version), } - if process_tags := process_tags.process_tags: - application["process_tags"] = process_tags + if p_tags := process_tags.process_tags: + application["process_tags"] = p_tags return application From ae202075f7192c5d5af84f571effc46782775b92 Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Tue, 18 Nov 2025 14:14:02 +0100 Subject: [PATCH 20/27] improving normalization --- ddtrace/internal/process_tags/__init__.py | 31 +++++++-- tests/internal/test_process_tags.py | 82 +++++++++++++++++------ 2 files changed, 85 insertions(+), 28 deletions(-) diff --git a/ddtrace/internal/process_tags/__init__.py b/ddtrace/internal/process_tags/__init__.py index bcb2e976ea5..a3876dae2c6 100644 --- a/ddtrace/internal/process_tags/__init__.py +++ b/ddtrace/internal/process_tags/__init__.py @@ -16,23 +16,42 @@ ENTRYPOINT_TYPE_SCRIPT = "script" ENTRYPOINT_BASEDIR_TAG = "entrypoint.basedir" -_INVALID_CHARS_PATTERN = re.compile(r"[^a-z0-9/._-]") _CONSECUTIVE_UNDERSCORES_PATTERN = re.compile(r"_{2,}") +_ALLOWED_CHARS = _ALLOWED_CHARS = frozenset("abcdefghijklmnopqrstuvwxyz0123456789/:._-") +MAX_LENGTH = 200 -def normalize_tag(value: str) -> str: - normalized = _INVALID_CHARS_PATTERN.sub("_", value.lower()) - normalized = _CONSECUTIVE_UNDERSCORES_PATTERN.sub("_", normalized) - return normalized.strip("_") +def normalize_tag_value(value: str) -> str: + # we copy the behavior of the agent which + # checks the size on the original value and not on + # an intermediary normalized step + if len(value) > MAX_LENGTH: + value = value[:MAX_LENGTH] + + result = value.lower() + + def is_allowed_char(char: str) -> str: + # ASCII alphanumeric and special chars: / : . _ - + if char in _ALLOWED_CHARS: + return char + # Unicode letters and digits + if char.isalpha() or char.isdigit(): + return char + return "_" + + result = "".join(is_allowed_char(char) for char in result) + result = _CONSECUTIVE_UNDERSCORES_PATTERN.sub("_", result) + return result.strip("_") def generate_process_tags() -> Optional[str]: + print(config._process_tags_enabled) if not config._process_tags_enabled: return None try: return ",".join( - f"{key}:{normalize_tag(value)}" + f"{key}:{normalize_tag_value(value)}" for key, value in sorted( [ (ENTRYPOINT_WORKDIR_TAG, os.path.basename(os.getcwd())), diff --git a/tests/internal/test_process_tags.py b/tests/internal/test_process_tags.py index 624e7707589..ac99788a793 100644 --- a/tests/internal/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -3,37 +3,73 @@ import pytest from ddtrace.internal import process_tags -from ddtrace.internal.process_tags import normalize_tag +from ddtrace.internal.process_tags import normalize_tag_value from ddtrace.internal.settings._config import config from tests.subprocesstest import run_in_subprocess from tests.utils import TracerTestCase from tests.utils import process_tag_reload +TEST_SCRIPT_PATH = "/path/to/test_script.py" +TEST_WORKDIR_PATH = "/path/to/workdir" + + @pytest.mark.parametrize( "input_tag,expected", [ - ("HelloWorld", "helloworld"), - ("Hello@World!", "hello_world"), - ("HeLLo123", "hello123"), - ("hello world", "hello_world"), - ("a/b.c_d-e", "a/b.c_d-e"), - ("héllø", "h_ll"), + # # Additional test cases from Go implementation + ("#test_starting_hash", "test_starting_hash"), + ("TestCAPSandSuch", "testcapsandsuch"), + ("Test Conversion Of Weird !@#$%^&**() Characters", "test_conversion_of_weird_characters"), + ("$#weird_starting", "weird_starting"), + ("allowed:c0l0ns", "allowed:c0l0ns"), + ("1love", "1love"), + ("/love2", "/love2"), + ("ünicöde", "ünicöde"), + ("ünicöde:metäl", "ünicöde:metäl"), + ("Data🐨dog🐶 繋がっ⛰てて", "data_dog_繋がっ_てて"), + (" spaces ", "spaces"), + (" #hashtag!@#spaces #__<># ", "hashtag_spaces"), + (":testing", ":testing"), + ("_foo", "foo"), + (":::test", ":::test"), + ("contiguous_____underscores", "contiguous_underscores"), + ("foo_", "foo"), + ("\u017Fodd_\u017Fcase\u017F", "\u017Fodd_\u017Fcase\u017F"), ("", ""), - ("💡⚡️", ""), - ("!foo@", "foo"), - ("123_abc.DEF-ghi/jkl", "123_abc.def-ghi/jkl"), - ("Env:Prod-Server#1", "env_prod-server_1"), - ("__hello__world__", "hello_world"), - ("___test___", "test"), - ("_leading", "leading"), - ("trailing_", "trailing"), - ("double__underscore", "double_underscore"), + (" ", ""), + ("ok", "ok"), + ("™Ö™Ö™™Ö™", "ö_ö_ö"), + ("AlsO:ök", "also:ök"), + (":still_ok", ":still_ok"), + ("___trim", "trim"), + ("12.:trim@", "12.:trim"), + ("12.:trim@@", "12.:trim"), + ("fun:ky__tag/1", "fun:ky_tag/1"), + ("fun:ky@tag/2", "fun:ky_tag/2"), + ("fun:ky@@@tag/3", "fun:ky_tag/3"), + ("tag:1/2.3", "tag:1/2.3"), + ("---fun:k####y_ta@#g/1_@@#", "---fun:k_y_ta_g/1"), + ("AlsO:œ#@ö))œk", "also:œ_ö_œk"), ("test\x99\x8faaa", "test_aaa"), + ("test\x99\x8f", "test"), + ("a" * 888, "a" * 200), + ("a" + "🐶" * 799 + "b", "a"), + ("a" + "\ufffd", "a"), + ("a" + "\ufffd" + "\ufffd", "a"), + ("a" + "\ufffd" + "\ufffd" + "b", "a_b"), + ( + "A00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + " 000000000000", + "a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + "_0", + ), ], ) def test_normalize_tag(input_tag, expected): - assert normalize_tag(input_tag) == expected + assert normalize_tag_value(input_tag) == expected class TestProcessTags(TracerTestCase): @@ -57,7 +93,7 @@ def test_process_tags_deactivated(self): @pytest.mark.snapshot def test_process_tags_activated(self): - with patch("sys.argv", ["/path/to/test_script.py"]), patch("os.getcwd", return_value="/path/to/workdir"): + with patch("sys.argv", [TEST_SCRIPT_PATH]), patch("os.getcwd", return_value=TEST_WORKDIR_PATH): config._process_tags_enabled = True process_tag_reload() @@ -67,7 +103,7 @@ def test_process_tags_activated(self): @pytest.mark.snapshot def test_process_tags_edge_case(self): - with patch("sys.argv", ["/test_script"]), patch("os.getcwd", return_value="/path/to/workdir"): + with patch("sys.argv", ["/test_script"]), patch("os.getcwd", return_value=TEST_WORKDIR_PATH): config._process_tags_enabled = True process_tag_reload() @@ -76,7 +112,7 @@ def test_process_tags_edge_case(self): @pytest.mark.snapshot def test_process_tags_error(self): - with patch("sys.argv", []), patch("os.getcwd", return_value="/path/to/workdir"): + with patch("sys.argv", []), patch("os.getcwd", return_value=TEST_WORKDIR_PATH): config._process_tags_enabled = True with self.override_global_config(dict(_telemetry_enabled=False)): @@ -89,12 +125,14 @@ def test_process_tags_error(self): # Check if debug log was called mock_log.debug.assert_called_once() call_args = mock_log.debug.call_args[0] - assert "failed to get process_tags" in call_args[0] + assert ( + "failed to get process_tags" in call_args[0] + ), f"Expected error message not found. Got: {call_args[0]}" @pytest.mark.snapshot @run_in_subprocess(env_overrides=dict(DD_TRACE_PARTIAL_FLUSH_ENABLED="true", DD_TRACE_PARTIAL_FLUSH_MIN_SPANS="2")) def test_process_tags_partial_flush(self): - with patch("sys.argv", ["/path/to/test_script.py"]), patch("os.getcwd", return_value="/path/to/workdir"): + with patch("sys.argv", [TEST_SCRIPT_PATH]), patch("os.getcwd", return_value=TEST_WORKDIR_PATH): config._process_tags_enabled = True process_tag_reload() From efe28faf7708600c26e759206ad1d5c76ae559ce Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Tue, 18 Nov 2025 16:07:37 +0100 Subject: [PATCH 21/27] remove print --- ddtrace/internal/process_tags/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ddtrace/internal/process_tags/__init__.py b/ddtrace/internal/process_tags/__init__.py index a3876dae2c6..a544374c466 100644 --- a/ddtrace/internal/process_tags/__init__.py +++ b/ddtrace/internal/process_tags/__init__.py @@ -45,7 +45,6 @@ def is_allowed_char(char: str) -> str: def generate_process_tags() -> Optional[str]: - print(config._process_tags_enabled) if not config._process_tags_enabled: return None From f957552544a61e18d12d73674bd882c28e29d787 Mon Sep 17 00:00:00 2001 From: Louis Tricot <75956635+dubloom@users.noreply.github.com> Date: Tue, 18 Nov 2025 17:28:33 +0100 Subject: [PATCH 22/27] Update tests/internal/test_process_tags.py --- tests/internal/test_process_tags.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/internal/test_process_tags.py b/tests/internal/test_process_tags.py index ac99788a793..8c50bcfaaf7 100644 --- a/tests/internal/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -17,7 +17,6 @@ @pytest.mark.parametrize( "input_tag,expected", [ - # # Additional test cases from Go implementation ("#test_starting_hash", "test_starting_hash"), ("TestCAPSandSuch", "testcapsandsuch"), ("Test Conversion Of Weird !@#$%^&**() Characters", "test_conversion_of_weird_characters"), From 914d52a3df6807b2a16a85dcae4e02329274386d Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Wed, 19 Nov 2025 10:37:42 +0100 Subject: [PATCH 23/27] add a test that activates the feature with env variable --- tests/internal/test_process_tags.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/internal/test_process_tags.py b/tests/internal/test_process_tags.py index 8c50bcfaaf7..de29fe14d89 100644 --- a/tests/internal/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -3,6 +3,11 @@ import pytest from ddtrace.internal import process_tags +from ddtrace.internal.constants import PROCESS_TAGS +from ddtrace.internal.process_tags import ENTRYPOINT_BASEDIR_TAG +from ddtrace.internal.process_tags import ENTRYPOINT_NAME_TAG +from ddtrace.internal.process_tags import ENTRYPOINT_TYPE_TAG +from ddtrace.internal.process_tags import ENTRYPOINT_WORKDIR_TAG from ddtrace.internal.process_tags import normalize_tag_value from ddtrace.internal.settings._config import config from tests.subprocesstest import run_in_subprocess @@ -141,3 +146,19 @@ def test_process_tags_partial_flush(self): pass with self.tracer.trace("child2"): pass + + @run_in_subprocess(env_overrides=dict(DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED="True")) + def test_process_tags_activated_with_env(self): + with self.tracer.trace("test"): + pass + + span = self.get_spans()[0] + + assert span is not None + assert PROCESS_TAGS in span._meta + + process_tags = span._meta[PROCESS_TAGS] + assert ENTRYPOINT_BASEDIR_TAG in process_tags + assert ENTRYPOINT_NAME_TAG in process_tags + assert ENTRYPOINT_TYPE_TAG in process_tags + assert ENTRYPOINT_WORKDIR_TAG in process_tags From 7395edbd2f9833ad72d17da55f9cda5655773057 Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Wed, 19 Nov 2025 10:50:59 +0100 Subject: [PATCH 24/27] fix tests --- tests/telemetry/test_data.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/tests/telemetry/test_data.py b/tests/telemetry/test_data.py index dc7403d38e9..d18d374faf6 100644 --- a/tests/telemetry/test_data.py +++ b/tests/telemetry/test_data.py @@ -13,7 +13,6 @@ from ddtrace.internal.telemetry.data import get_application from ddtrace.internal.telemetry.data import get_host_info from ddtrace.internal.telemetry.data import get_hostname -from tests.utils import process_tag_reload def test_get_application(): @@ -44,18 +43,12 @@ def test_get_application_with_values(): assert application["env"] == "staging" +@pytest.mark.subprocess(env={"DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED": "True"}) def test_get_application_with_process_tags(): - from ddtrace.internal.settings._config import config + from ddtrace.internal.telemetry.data import get_application - try: - config._process_tags_enabled = True - process_tag_reload() - - application = get_application("", "", "") - assert "process_tags" in application - finally: - config._process_tags_enabled = False - process_tag_reload() + application = get_application("", "", "") + assert "process_tags" in application def test_application_with_setenv(run_python_code_in_subprocess, monkeypatch): From fdd64804035d0ad0b5dd19d1df91db9c317e8025 Mon Sep 17 00:00:00 2001 From: Louis Tricot <75956635+dubloom@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:03:56 +0100 Subject: [PATCH 25/27] chore(di): add process_tags (#15225) This PR implements this [RFC](https://docs.google.com/document/d/1AFdLUuVk70i0bJd5335-RxqsvwAV9ovAqcO2z5mEMbA/edit?pli=1&tab=t.0#heading=h.s9l1lctqlg11) for dynamic instrumentation. Add process_tags to dynamic instrumentation payload ## Testing - Check that process tags are not included if deactivated - Check the right process tags are set in the payload when activated --- ddtrace/debugging/_encoding.py | 4 +++ tests/debugging/test_encoding.py | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/ddtrace/debugging/_encoding.py b/ddtrace/debugging/_encoding.py index 3d4ecbc0f8c..a558200f113 100644 --- a/ddtrace/debugging/_encoding.py +++ b/ddtrace/debugging/_encoding.py @@ -20,6 +20,7 @@ from ddtrace.debugging._signal.log import LogSignal from ddtrace.debugging._signal.snapshot import Snapshot from ddtrace.internal import forksafe +from ddtrace.internal import process_tags from ddtrace.internal._encoding import BufferFull from ddtrace.internal.logger import get_logger from ddtrace.internal.utils.formats import format_trace_id @@ -113,6 +114,9 @@ def _build_log_track_payload( "timestamp": int(signal.timestamp * 1e3), # milliseconds, } + if p_tags := process_tags.process_tags: + payload["process_tags"] = p_tags + # Add the correlation IDs if available if context is not None and context.trace_id is not None: payload["dd"] = { diff --git a/tests/debugging/test_encoding.py b/tests/debugging/test_encoding.py index 7604c98afe3..225dfbb975b 100644 --- a/tests/debugging/test_encoding.py +++ b/tests/debugging/test_encoding.py @@ -250,6 +250,61 @@ def test_batch_json_encoder(): assert queue.count == 0 +def test_process_tags_are_not_included_by_default(): + s = Snapshot( + probe=create_snapshot_line_probe(probe_id="batch-test", source_file="foo.py", line=42), + frame=inspect.currentframe(), + thread=threading.current_thread(), + ) + buffer_size = 30 * (1 << 20) + queue = SignalQueue(encoder=LogSignalJsonEncoder(None), buffer_size=buffer_size) + + s.line({}) + + queue = SignalQueue(encoder=LogSignalJsonEncoder("test-service")) + queue.put(s) + data = queue.flush() + assert data is not None + payload, _ = data + decoded = json.loads(payload.decode()) + assert "process_tags" not in decoded[0] + + +@pytest.mark.subprocess( + env=dict( + DD_EXPERIMENTAL_PROPAGATE_PROCESS_TAGS_ENABLED="true", + ) +) +def test_process_tags_are_included(): + import inspect + import json + import threading + + from ddtrace.debugging._encoding import LogSignalJsonEncoder + from ddtrace.debugging._encoding import SignalQueue + from ddtrace.debugging._signal.snapshot import Snapshot + from tests.debugging.utils import create_snapshot_line_probe + + s = Snapshot( + probe=create_snapshot_line_probe(probe_id="batch-test", source_file="foo.py", line=42), + frame=inspect.currentframe(), + thread=threading.current_thread(), + ) + buffer_size = 30 * (1 << 20) + queue = SignalQueue(encoder=LogSignalJsonEncoder(None), buffer_size=buffer_size) + + s.line({}) + + queue = SignalQueue(encoder=LogSignalJsonEncoder("test-service")) + queue.put(s) + data = queue.flush() + assert data is not None + payload, _ = data + decoded = json.loads(payload.decode()) + + assert "process_tags" in decoded[0] + + def test_batch_flush_reencode(): s = Snapshot( probe=create_snapshot_line_probe(probe_id="batch-test", source_file="foo.py", line=42), From 834196979a6eafa0bdb4794df42715e3df92ae9e Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Wed, 19 Nov 2025 18:09:29 +0100 Subject: [PATCH 26/27] remove circular dependency --- ddtrace/internal/process_tags/__init__.py | 4 ++-- ddtrace/internal/settings/process_tags.py | 15 +++++++++++++++ ddtrace/internal/telemetry/data.py | 4 +--- tests/internal/test_process_tags.py | 16 ++++++++-------- 4 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 ddtrace/internal/settings/process_tags.py diff --git a/ddtrace/internal/process_tags/__init__.py b/ddtrace/internal/process_tags/__init__.py index a544374c466..1a4092232bf 100644 --- a/ddtrace/internal/process_tags/__init__.py +++ b/ddtrace/internal/process_tags/__init__.py @@ -5,7 +5,7 @@ from typing import Optional from ddtrace.internal.logger import get_logger -from ddtrace.internal.settings._config import config +from ddtrace.internal.settings.process_tags import process_tags_config as config log = get_logger(__name__) @@ -45,7 +45,7 @@ def is_allowed_char(char: str) -> str: def generate_process_tags() -> Optional[str]: - if not config._process_tags_enabled: + if not config.enabled: return None try: diff --git a/ddtrace/internal/settings/process_tags.py b/ddtrace/internal/settings/process_tags.py new file mode 100644 index 00000000000..81c59ec48aa --- /dev/null +++ b/ddtrace/internal/settings/process_tags.py @@ -0,0 +1,15 @@ +from ddtrace.internal.settings._core import DDConfig + + +class ProcessTagsConfig(DDConfig): + __prefix__ = "dd" + + enabled = DDConfig.v( + bool, + "experimental.propagate.process.tags.enabled", + default=False, + help="Enables process tags in products payload", + ) + + +process_tags_config = ProcessTagsConfig() diff --git a/ddtrace/internal/telemetry/data.py b/ddtrace/internal/telemetry/data.py index 628f5d43507..30d5a153c7e 100644 --- a/ddtrace/internal/telemetry/data.py +++ b/ddtrace/internal/telemetry/data.py @@ -7,6 +7,7 @@ from typing import List # noqa:F401 from typing import Tuple # noqa:F401 +from ddtrace.internal import process_tags from ddtrace.internal.constants import DEFAULT_SERVICE_NAME from ddtrace.internal.packages import get_module_distribution_versions from ddtrace.internal.runtime.container import get_container_info @@ -56,9 +57,6 @@ def _get_application(key): This helper packs and unpacks get_application arguments to support caching. Cached() annotation only supports functions with one argument """ - # avoid circular dependency - from ddtrace.internal import process_tags - service, version, env = key application = { diff --git a/tests/internal/test_process_tags.py b/tests/internal/test_process_tags.py index de29fe14d89..9f16400e020 100644 --- a/tests/internal/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -9,7 +9,7 @@ from ddtrace.internal.process_tags import ENTRYPOINT_TYPE_TAG from ddtrace.internal.process_tags import ENTRYPOINT_WORKDIR_TAG from ddtrace.internal.process_tags import normalize_tag_value -from ddtrace.internal.settings._config import config +from ddtrace.internal.settings.process_tags import process_tags_config as config from tests.subprocesstest import run_in_subprocess from tests.utils import TracerTestCase from tests.utils import process_tag_reload @@ -79,17 +79,17 @@ def test_normalize_tag(input_tag, expected): class TestProcessTags(TracerTestCase): def setUp(self): super(TestProcessTags, self).setUp() - self._original_process_tags_enabled = config._process_tags_enabled + self._original_process_tags_enabled = config.enabled self._original_process_tags = process_tags.process_tags def tearDown(self): - config._process_tags_enabled = self._original_process_tags_enabled + config.enabled = self._original_process_tags_enabled process_tags.process_tags = self._original_process_tags super().tearDown() @pytest.mark.snapshot def test_process_tags_deactivated(self): - config._process_tags_enabled = False + config.enabled = False # type: ignore[assignment] process_tag_reload() with self.tracer.trace("test"): @@ -98,7 +98,7 @@ def test_process_tags_deactivated(self): @pytest.mark.snapshot def test_process_tags_activated(self): with patch("sys.argv", [TEST_SCRIPT_PATH]), patch("os.getcwd", return_value=TEST_WORKDIR_PATH): - config._process_tags_enabled = True + config.enabled = True # type: ignore[assignment] process_tag_reload() with self.tracer.trace("parent"): @@ -108,7 +108,7 @@ def test_process_tags_activated(self): @pytest.mark.snapshot def test_process_tags_edge_case(self): with patch("sys.argv", ["/test_script"]), patch("os.getcwd", return_value=TEST_WORKDIR_PATH): - config._process_tags_enabled = True + config.enabled = True # type: ignore[assignment] process_tag_reload() with self.tracer.trace("span"): @@ -117,7 +117,7 @@ def test_process_tags_edge_case(self): @pytest.mark.snapshot def test_process_tags_error(self): with patch("sys.argv", []), patch("os.getcwd", return_value=TEST_WORKDIR_PATH): - config._process_tags_enabled = True + config.enabled = True # type: ignore[assignment] with self.override_global_config(dict(_telemetry_enabled=False)): with patch("ddtrace.internal.process_tags.log") as mock_log: @@ -137,7 +137,7 @@ def test_process_tags_error(self): @run_in_subprocess(env_overrides=dict(DD_TRACE_PARTIAL_FLUSH_ENABLED="true", DD_TRACE_PARTIAL_FLUSH_MIN_SPANS="2")) def test_process_tags_partial_flush(self): with patch("sys.argv", [TEST_SCRIPT_PATH]), patch("os.getcwd", return_value=TEST_WORKDIR_PATH): - config._process_tags_enabled = True + config.enabled = True # type: ignore[assignment] process_tag_reload() with self.override_global_config(dict(_partial_flush_enabled=True, _partial_flush_min_spans=2)): From aa23d07d5f3dab3d85b1ea9145d9cef08a4e619c Mon Sep 17 00:00:00 2001 From: Louis Tricot Date: Wed, 19 Nov 2025 18:16:57 +0100 Subject: [PATCH 27/27] lint --- tests/internal/test_process_tags.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/internal/test_process_tags.py b/tests/internal/test_process_tags.py index de29fe14d89..8ccf58addbc 100644 --- a/tests/internal/test_process_tags.py +++ b/tests/internal/test_process_tags.py @@ -39,7 +39,7 @@ (":::test", ":::test"), ("contiguous_____underscores", "contiguous_underscores"), ("foo_", "foo"), - ("\u017Fodd_\u017Fcase\u017F", "\u017Fodd_\u017Fcase\u017F"), + ("\u017fodd_\u017fcase\u017f", "\u017fodd_\u017fcase\u017f"), ("", ""), (" ", ""), ("ok", "ok"), @@ -129,9 +129,9 @@ def test_process_tags_error(self): # Check if debug log was called mock_log.debug.assert_called_once() call_args = mock_log.debug.call_args[0] - assert ( - "failed to get process_tags" in call_args[0] - ), f"Expected error message not found. Got: {call_args[0]}" + assert "failed to get process_tags" in call_args[0], ( + f"Expected error message not found. Got: {call_args[0]}" + ) @pytest.mark.snapshot @run_in_subprocess(env_overrides=dict(DD_TRACE_PARTIAL_FLUSH_ENABLED="true", DD_TRACE_PARTIAL_FLUSH_MIN_SPANS="2"))