Skip to content

Commit 89fb1ca

Browse files
chore(service-naming): improve inferred service naming algorithm [backport 2.17] (#11475)
Backport 110dcfa from #11458 to 2.17. ## Motivation Updates inferred service naming algorithm. Previously, the service naming algorithm would skip over any flag arguments (args starting with `-`), as well as the following argument as it was assumed to be the argument value. The update changes the algorithm to run again if no service name was found the first time. The second time, the algorithm will not skip arguments that were preceded by a flag argument. Effect: -- Previous Behavior: `pytest --no-cov my/file.py` -> service name = `""` -- New Behavior: `pytest --no-cov my/file.py` -> service name = `"my.file"` Additionally adds check to ensure a python module included after `-m` module is importable before using it as the service name. Also updates the service naming algorithm to use try-catches to prevent any unforeseen errors. Adds many more test cases, fixes a few snapshots.
1 parent e97c293 commit 89fb1ca

19 files changed

+228
-86
lines changed

ddtrace/contrib/internal/bottle/trace.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class TracePlugin(object):
2222
api = 2
2323

2424
def __init__(self, service="bottle", tracer=None, distributed_tracing=None):
25-
self.service = config.service or service
25+
self.service = config._get_service(default=service)
2626
self.tracer = tracer or ddtrace.tracer
2727
if distributed_tracing is not None:
2828
config.bottle.distributed_tracing = distributed_tracing

ddtrace/settings/_inferred_base_service.py

Lines changed: 77 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import fnmatch
2+
import importlib.util
23
import os
34
import pathlib
45
import re
@@ -8,6 +9,11 @@
89
from typing import Optional
910
from typing import Tuple
1011

12+
from ..internal.logger import get_logger
13+
14+
15+
log = get_logger(__name__)
16+
1117

1218
INIT_PY = "__init__.py"
1319
ALL_PY_FILES = "*.py"
@@ -33,7 +39,7 @@ def __init__(self, environ: Dict[str, str]):
3339
# - Match /python, /python3.7, etc.
3440
self.pattern = r"(^|/)(?!.*\.py$)(" + re.escape("python") + r"(\d+\.\d+)?$)"
3541

36-
def detect(self, args: List[str]) -> Optional[ServiceMetadata]:
42+
def detect(self, args: List[str], skip_args_preceded_by_flags=True) -> Optional[ServiceMetadata]:
3743
"""
3844
Detects and returns service metadata based on the provided list of arguments.
3945
@@ -59,22 +65,27 @@ def detect(self, args: List[str]) -> Optional[ServiceMetadata]:
5965
has_flag_prefix = arg.startswith("-") and not arg.startswith("--ddtrace")
6066
is_env_variable = "=" in arg
6167

62-
should_skip_arg = prev_arg_is_flag or has_flag_prefix or is_env_variable
68+
should_skip_arg = (prev_arg_is_flag and skip_args_preceded_by_flags) or has_flag_prefix or is_env_variable
6369

6470
if module_flag:
65-
return ServiceMetadata(arg)
71+
if _module_exists(arg):
72+
return ServiceMetadata(arg)
6673

6774
if not should_skip_arg:
68-
abs_path = pathlib.Path(arg).resolve()
69-
if not abs_path.exists():
70-
continue
71-
stripped = abs_path
72-
if not stripped.is_dir():
73-
stripped = stripped.parent
74-
value, ok = self.deduce_package_name(stripped)
75-
if ok:
76-
return ServiceMetadata(value)
77-
return ServiceMetadata(self.find_nearest_top_level(stripped))
75+
try:
76+
abs_path = pathlib.Path(arg).resolve()
77+
if not abs_path.exists():
78+
continue
79+
stripped = abs_path
80+
if not stripped.is_dir():
81+
stripped = stripped.parent
82+
value, ok = self.deduce_package_name(stripped)
83+
if ok:
84+
return ServiceMetadata(value)
85+
return ServiceMetadata(self.find_nearest_top_level(stripped))
86+
except Exception as ex:
87+
# Catch any unexpected errors
88+
log.debug("Unexpected error while processing argument: ", arg, "Exception: ", ex)
7889

7990
if has_flag_prefix and arg == "-m":
8091
module_flag = True
@@ -145,39 +156,51 @@ def detect_service(args: List[str]) -> Optional[str]:
145156
if cache_key in CACHE:
146157
return CACHE.get(cache_key)
147158

148-
# Check both the included command args as well as the executable being run
149-
possible_commands = [*args, sys.executable]
150-
executable_args = set()
151-
152-
# List of detectors to try in order
153-
detectors = {}
154-
for detector_class in detector_classes:
155-
detector_instance = detector_class(dict(os.environ))
156-
157-
for i, command in enumerate(possible_commands):
158-
detector_name = detector_instance.name
159-
160-
if detector_instance.matches(command):
161-
detectors.update({detector_name: detector_instance})
162-
# append to a list of arg indexes to ignore since they are executables
163-
executable_args.add(i)
164-
continue
165-
elif _is_executable(command):
166-
# append to a list of arg indexes to ignore since they are executables
167-
executable_args.add(i)
168-
169-
args_to_search = []
170-
for i, arg in enumerate(args):
171-
# skip any executable args
172-
if i not in executable_args:
173-
args_to_search.append(arg)
174-
175-
# Iterate through the matched detectors
176-
for detector in detectors.values():
177-
metadata = detector.detect(args_to_search)
178-
if metadata and metadata.name:
179-
CACHE[cache_key] = metadata.name
180-
return metadata.name
159+
try:
160+
# Check both the included command args as well as the executable being run
161+
possible_commands = [*args, sys.executable]
162+
executable_args = set()
163+
164+
# List of detectors to try in order
165+
detectors = {}
166+
for detector_class in detector_classes:
167+
detector_instance = detector_class(dict(os.environ))
168+
169+
for i, command in enumerate(possible_commands):
170+
detector_name = detector_instance.name
171+
172+
if detector_instance.matches(command):
173+
detectors.update({detector_name: detector_instance})
174+
# append to a list of arg indexes to ignore since they are executables
175+
executable_args.add(i)
176+
continue
177+
elif _is_executable(command):
178+
# append to a list of arg indexes to ignore since they are executables
179+
executable_args.add(i)
180+
181+
args_to_search = []
182+
for i, arg in enumerate(args):
183+
# skip any executable args
184+
if i not in executable_args:
185+
args_to_search.append(arg)
186+
187+
# Iterate through the matched detectors
188+
for detector in detectors.values():
189+
metadata = detector.detect(args_to_search)
190+
if metadata and metadata.name:
191+
CACHE[cache_key] = metadata.name
192+
return metadata.name
193+
194+
# Iterate through the matched detectors again, this time not skipping args preceded by flag args
195+
for detector in detectors.values():
196+
metadata = detector.detect(args_to_search, skip_args_preceded_by_flags=False)
197+
if metadata and metadata.name:
198+
CACHE[cache_key] = metadata.name
199+
return metadata.name
200+
except Exception as ex:
201+
# Catch any unexpected errors to be extra safe
202+
log.warning("Unexpected error during inferred base service detection: ", ex)
203+
181204
CACHE[cache_key] = None
182205
return None
183206

@@ -195,3 +218,11 @@ def _is_executable(file_path: str) -> bool:
195218
directory = os.path.dirname(directory)
196219

197220
return False
221+
222+
223+
def _module_exists(module_name: str) -> bool:
224+
"""Check if a module can be imported."""
225+
try:
226+
return importlib.util.find_spec(module_name) is not None
227+
except ModuleNotFoundError:
228+
return False

tests/conftest.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,20 @@ def create_ddtrace_subprocess_dir_and_return_test_pyfile(tmpdir):
178178
return pyfile
179179

180180

181+
@pytest.fixture
182+
def ddtrace_tmp_path(tmp_path):
183+
# Create a test dir named `ddtrace_subprocess_dir` that will be used by the tracers
184+
ddtrace_dir = tmp_path / DEFAULT_DDTRACE_SUBPROCESS_TEST_SERVICE_NAME
185+
ddtrace_dir.mkdir(exist_ok=True) # Create the directory if it doesn't exist
186+
187+
# Check for __init__.py and create it if it doesn't exist
188+
init_file = ddtrace_dir / "__init__.py"
189+
if not init_file.exists():
190+
init_file.write_text("") # Create an empty __init__.py file
191+
192+
return ddtrace_dir
193+
194+
181195
@pytest.fixture
182196
def run_python_code_in_subprocess(tmpdir):
183197
def _run(code, **kwargs):

tests/contrib/gunicorn/test_gunicorn.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def test_no_known_errors_occur(tmp_path):
187187

188188
@flaky(until=1706677200)
189189
@pytest.mark.skipif(sys.version_info >= (3, 11), reason="Gunicorn is only supported up to 3.10")
190-
def test_span_schematization(tmp_path):
190+
def test_span_schematization(ddtrace_tmp_path):
191191
for schema_version in [None, "v0", "v1"]:
192192
for service_name in [None, "mysvc"]:
193193
gunicorn_settings = _gunicorn_settings_factory(
@@ -201,7 +201,7 @@ def test_span_schematization(tmp_path):
201201
),
202202
ignores=["meta.result_class"],
203203
):
204-
with gunicorn_server(gunicorn_settings, tmp_path) as context:
204+
with gunicorn_server(gunicorn_settings, ddtrace_tmp_path) as context:
205205
_, client = context
206206
response = client.get("/")
207207
assert response.status_code == 200

tests/internal/service_name/test_inferred_base_service.py

Lines changed: 99 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import pathlib
2+
import shlex
23
import tempfile
34
from unittest.mock import patch
45

56
import pytest
67

8+
from ddtrace.settings._inferred_base_service import _module_exists
79
from ddtrace.settings._inferred_base_service import detect_service
810

911

@@ -21,7 +23,9 @@ def mock_file_system():
2123
(base_path / "venv" / "bin" / "gunicorn").mkdir(parents=True)
2224

2325
# add a test dir
24-
(base_path / "tests" / "contrib" / "aiohttp").mkdir(parents=True)
26+
(base_path / "tests" / "contrib" / "aiohttp" / "app").mkdir(parents=True)
27+
28+
# other cases
2529

2630
(base_path / "modules" / "m1" / "first" / "nice" / "package").mkdir(parents=True)
2731
(base_path / "modules" / "m2").mkdir(parents=True)
@@ -35,15 +39,19 @@ def mock_file_system():
3539
(base_path / "venv" / "bin" / "python3.11" / "gunicorn" / "__init__.py").mkdir(parents=True)
3640
(base_path / "venv" / "bin" / "gunicorn" / "__init__.py").touch()
3741

42+
# Create `__init__.py` files that indicate packages
3843
(base_path / "modules" / "m1" / "first" / "nice" / "package" / "__init__.py").touch()
3944
(base_path / "modules" / "m1" / "first" / "nice" / "__init__.py").touch()
40-
(base_path / "modules" / "m1" / "first" / "nice" / "something.py").touch()
45+
(base_path / "modules" / "m1" / "first" / "nice" / "app.py").touch()
4146
(base_path / "modules" / "m1" / "first" / "__init__.py").touch()
4247
(base_path / "modules" / "m1" / "__init__.py").touch()
48+
(base_path / "modules" / "m2" / "__init__.py").touch()
4349
(base_path / "apps" / "app1" / "__main__.py").touch()
4450
(base_path / "apps" / "app2" / "cmd" / "run.py").touch()
4551
(base_path / "apps" / "app2" / "setup.py").touch()
4652

53+
(base_path / "tests" / "contrib" / "aiohttp" / "app" / "web.py").touch()
54+
(base_path / "tests" / "contrib" / "aiohttp" / "app" / "__init__.py").touch()
4755
(base_path / "tests" / "contrib" / "aiohttp" / "test.py").touch()
4856
(base_path / "tests" / "contrib" / "aiohttp" / "__init__.py").touch()
4957
(base_path / "tests" / "contrib" / "__init__.py").touch()
@@ -59,9 +67,13 @@ def mock_file_system():
5967
@pytest.mark.parametrize(
6068
"cmd,expected",
6169
[
70+
("python tests/contrib/aiohttp/app/web.py", "tests.contrib.aiohttp.app"),
71+
("python tests/contrib/aiohttp", "tests.contrib.aiohttp"),
72+
("python tests/contrib", "tests.contrib"),
73+
("python tests", "tests"),
6274
("python modules/m1/first/nice/package", "m1.first.nice.package"),
6375
("python modules/m1/first/nice", "m1.first.nice"),
64-
("python modules/m1/first/nice/something.py", "m1.first.nice"),
76+
("python modules/m1/first/nice/app.py", "m1.first.nice"),
6577
("python modules/m1/first", "m1.first"),
6678
("python modules/m2", "m2"),
6779
("python apps/app1", "app1"),
@@ -75,17 +87,98 @@ def mock_file_system():
7587
("venv/bin/python3.11/ddtrace-run python apps/app2/setup.py", "app2"),
7688
("ddtrace-run python apps/app2/setup.py", "app2"),
7789
("python3.12 apps/app2/cmd/run.py", "app2"),
78-
("python -m m1.first.nice.package", "m1.first.nice.package"),
90+
("python -m tests.contrib.aiohttp.app.web", "tests.contrib.aiohttp.app.web"),
91+
("python -m tests.contrib.aiohttp.app", "tests.contrib.aiohttp.app"),
92+
("python -m tests.contrib.aiohttp", "tests.contrib.aiohttp"),
93+
("python -m tests.contrib", "tests.contrib"),
94+
("python -m tests", "tests"),
7995
("python -m http.server 8000", "http.server"),
96+
("python --some-flag apps/app1", "app1"),
8097
# pytest
8198
("pytest tests/contrib/aiohttp", "tests.contrib.aiohttp"),
8299
("pytest --ddtrace tests/contrib/aiohttp", "tests.contrib.aiohttp"),
100+
("pytest --no-cov tests/contrib/aiohttp", "tests.contrib.aiohttp"),
83101
],
84102
)
85-
def test_python_detector(cmd, expected, mock_file_system):
103+
def test_python_detector_service_name_should_exist_file_exists(cmd, expected, mock_file_system):
86104
# Mock the current working directory to the test_modules path
87105
with patch("os.getcwd", return_value=str(mock_file_system)):
88-
cmd_parts = cmd.split(" ")
106+
cmd_parts = shlex.split(cmd)
89107
detected_name = detect_service(cmd_parts)
90108

91109
assert detected_name == expected, f"Test failed for command: [{cmd}]"
110+
111+
112+
@pytest.mark.parametrize(
113+
"cmd,expected",
114+
[
115+
# Commands that should not produce a service name
116+
("", None), # Empty command
117+
("python non_existing_file.py", None), # Non-existing Python script
118+
("python invalid_script.py", None), # Invalid script that isn't found
119+
("gunicorn app:app", None), # Non-Python command
120+
("ls -la", None), # Non-Python random command
121+
("cat README.md", None), # Another random command
122+
("python -m non_existing_module", None), # Non-existing Python module
123+
("python -c 'print([])'", None), # Python inline code not producing a service
124+
("python -m -c 'print([]])'", None), # Inline code with module flag
125+
("echo 'Hello, World!'", None), # Not a Python service
126+
("python3.11 /path/to/some/non_python_file.txt", None), # Non-Python file
127+
("/usr/bin/ls", None), # Another system command
128+
("some_executable --ddtrace hello", None),
129+
("python version", None),
130+
("python -m -v --hello=maam", None),
131+
# error produced from a test, ensure an arg that is very long doesn't break stuff
132+
(
133+
"ddtrace-run pytest -k 'not test_reloader and not test_reload_listeners and not "
134+
+ "test_no_exceptions_when_cancel_pending_request and not test_add_signal and not "
135+
+ "test_ode_removes and not test_skip_touchup and not test_dispatch_signal_triggers"
136+
+ " and not test_keep_alive_connection_context and not test_redirect_with_params and"
137+
+ " not test_keep_alive_client_timeout and not test_logger_vhosts and not test_ssl_in_multiprocess_mode'",
138+
None,
139+
),
140+
],
141+
)
142+
def test_no_service_name(cmd, expected, mock_file_system):
143+
with patch("os.getcwd", return_value=str(mock_file_system)):
144+
cmd_parts = shlex.split(cmd)
145+
detected_name = detect_service(cmd_parts)
146+
147+
assert detected_name == expected, f"Test failed for command: [{cmd}]"
148+
149+
150+
@pytest.mark.parametrize(
151+
"cmd,expected",
152+
[
153+
# Command that is too long
154+
("python " + " ".join(["arg"] * 1000), None), # Excessively long command
155+
# Path with special characters
156+
(r"python /path/with/special/characters/!@#$%^&*()_/some_script.py", None), # Special characters
157+
# Path too deep
158+
(f"python {'/'.join(['deep' * 50])}/script.py", None), # Excessively deep path
159+
],
160+
)
161+
def test_chaos(cmd, expected, mock_file_system):
162+
with patch("os.getcwd", return_value=str(mock_file_system)):
163+
cmd_parts = shlex.split(cmd)
164+
detected_name = detect_service(cmd_parts)
165+
166+
assert detected_name == expected, f"Chaos test failed for command: [{cmd}]"
167+
168+
169+
@pytest.mark.parametrize(
170+
"module_name,should_exist",
171+
[
172+
("tests.contrib.aiohttp.app.web", True),
173+
("tests.contrib.aiohttp.app", True),
174+
("tests.contrib.aiohttp", True),
175+
("tests.contrib", True),
176+
("tests", True),
177+
("tests.releasenotes", False),
178+
("non_existing_module", False),
179+
],
180+
)
181+
def test_module_exists(module_name, should_exist):
182+
exists = _module_exists(module_name)
183+
184+
assert exists == should_exist, f"Module {module_name} existence check failed."

0 commit comments

Comments
 (0)