Skip to content

Commit 0397b54

Browse files
committed
Introduce a bootstrap command to auto-install packages
This commit introduces a new boostrap command that is shipped as part of the opentelemetry-auto-instrumentation package. The command detects installed libraries and installs the relevant auto-instrumentation packages.
1 parent e0fd435 commit 0397b54

File tree

4 files changed

+347
-3
lines changed

4 files changed

+347
-3
lines changed

Diff for: opentelemetry-auto-instrumentation/setup.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ where = src
4949
[options.entry_points]
5050
console_scripts =
5151
opentelemetry-auto-instrumentation = opentelemetry.auto_instrumentation.auto_instrumentation:run
52+
opentelemetry-bootstrap = opentelemetry.auto_instrumentation.bootstrap:run

Diff for: opentelemetry-auto-instrumentation/src/opentelemetry/auto_instrumentation/__init__.py

+18-3
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
# limitations under the License.
1414

1515
"""
16-
Usage
17-
-----
1816
19-
This package provides a command that automatically instruments a program:
17+
This package provides a couple of commands that help automatically instruments a program:
18+
19+
opentelemetry-auto-instrumentation
20+
-----------------------------------
2021
2122
::
2223
@@ -25,4 +26,18 @@
2526
The code in ``program.py`` needs to use one of the packages for which there is
2627
an OpenTelemetry integration. For a list of the available integrations please
2728
check :doc:`here <../../index>`.
29+
30+
31+
opentelemetry-bootstrap
32+
------------------------
33+
34+
::
35+
36+
opentelemetry-bootstrap --action=install|requirements
37+
38+
This commands inspects the active Python site-packages and figures out which
39+
instrumentation packages the user might want to install. By default it prints out
40+
a list of the suggested instrumentation packages which can be added to a requirements.txt
41+
file. It also supports installing the suggested packages when run with :code:`--action=install`
42+
flag.
2843
"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright The OpenTelemetry Authors
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
import argparse
18+
import pkgutil
19+
import subprocess
20+
import sys
21+
from logging import getLogger
22+
23+
logger = getLogger(__file__)
24+
25+
26+
# target library to desired instrumentor path/versioned package name
27+
instrumentations = {
28+
"dbapi": "opentelemetry-ext-dbapi>=0.8.8",
29+
"django": "opentelemetry-ext-django>=0.8.8",
30+
"flask": "opentelemetry-ext-flask>=0.8.8",
31+
"grpc": "opentelemetry-ext-grpc>=0.8.8",
32+
"requests": "opentelemetry-ext-requests>=0.8.8",
33+
"jinja2": "opentelemetry-ext-jinja2>=0.8.8",
34+
"mysql": "opentelemetry-ext-mysql>=0.8.8",
35+
"psycopg2": "opentelemetry-ext-psycopg2>=0.8.8",
36+
"pymongo": "opentelemetry-ext-pymongo>=0.8.8",
37+
"pymysql": "opentelemetry-ext-pymysql>=0.8.8",
38+
"redis": "opentelemetry-ext-redis>=0.8.8",
39+
"sqlalchemy": "opentelemetry-ext-sqlalchemy>=0.8.8",
40+
"wsgi": "opentelemetry-ext-wsgi>=0.8.8",
41+
}
42+
43+
# relevant instrumentors and tracers to uninstall and check for conflicts for target libraries
44+
libraries = {
45+
"dbapi": ("opentelemetry-ext-dbapi",),
46+
"django": ("opentelemetry-ext-django",),
47+
"flask": ("opentelemetry-ext-flask",),
48+
"grpc": ("opentelemetry-ext-grpc",),
49+
"requests": ("opentelemetry-ext-requests",),
50+
"jinja2": ("opentelemetry-ext-jinja2",),
51+
"mysql": ("opentelemetry-ext-mysql",),
52+
"psycopg2": ("opentelemetry-ext-psycopg2",),
53+
"pymongo": ("opentelemetry-ext-pymongo",),
54+
"pymysql": ("opentelemetry-ext-pymysql",),
55+
"redis": ("opentelemetry-ext-redis",),
56+
"sqlalchemy": ("opentelemetry-ext-sqlalchemy",),
57+
"wsgi": ("opentelemetry-ext-wsgi",),
58+
}
59+
60+
61+
def _install_package(library, instrumentation):
62+
"""
63+
Ensures that desired version is installed w/o upgrading its dependencies
64+
by uninstalling where necessary (if `target` is not provided).
65+
66+
67+
OpenTelemetry auto-instrumentation packages often have traced libraries
68+
as instrumentation dependency (e.g. flask for opentelemetry-ext-flask),
69+
so using -I on library could cause likely undesired Flask upgrade.
70+
Using --no-dependencies alone would leave potential for nonfunctional
71+
installations.
72+
"""
73+
pip_list = _sys_pip_freeze()
74+
for package in libraries[library]:
75+
if "{}==".format(package).lower() in pip_list:
76+
logger.info(
77+
"Existing %s installation detected. Uninstalling.", package
78+
)
79+
_sys_pip_uninstall(package)
80+
_sys_pip_install(instrumentation)
81+
82+
83+
def _syscall(func):
84+
def wrapper(package=None):
85+
try:
86+
if package:
87+
return func(package)
88+
return func()
89+
except subprocess.SubprocessError as exp:
90+
cmd = getattr(exp, "cmd", None)
91+
if cmd:
92+
msg = 'Error calling system command "{0}"'.format(
93+
" ".join(cmd)
94+
)
95+
if package:
96+
msg = '{0} for package "{1}"'.format(msg, package)
97+
raise RuntimeError(msg)
98+
99+
return wrapper
100+
101+
102+
@_syscall
103+
def _sys_pip_freeze():
104+
return (
105+
subprocess.check_output([sys.executable, "-m", "pip", "freeze"])
106+
.decode()
107+
.lower()
108+
)
109+
110+
111+
@_syscall
112+
def _sys_pip_install(package):
113+
# explicit upgrade strategy to override potential pip config
114+
subprocess.check_call(
115+
[
116+
sys.executable,
117+
"-m",
118+
"pip",
119+
"install",
120+
"-U",
121+
"--upgrade-strategy",
122+
"only-if-needed",
123+
package,
124+
]
125+
)
126+
127+
128+
@_syscall
129+
def _sys_pip_uninstall(package):
130+
subprocess.check_call(
131+
[sys.executable, "-m", "pip", "uninstall", "-y", package]
132+
)
133+
134+
135+
def _pip_check():
136+
"""Ensures none of the instrumentations have dependency conflicts.
137+
Clean check reported as:
138+
'No broken requirements found.'
139+
Dependency conflicts are reported as:
140+
'opentelemetry-ext-flask 1.0.1 has requirement opentelemetry-sdk<2.0,>=1.0, but you have opentelemetry-sdk 0.5.'
141+
To not be too restrictive, we'll only check for relevant packages.
142+
"""
143+
check_pipe = subprocess.Popen(
144+
[sys.executable, "-m", "pip", "check"], stdout=subprocess.PIPE
145+
)
146+
pip_check = check_pipe.communicate()[0].decode()
147+
pip_check_lower = pip_check.lower()
148+
for package_tup in libraries.values():
149+
for package in package_tup:
150+
if package.lower() in pip_check_lower:
151+
raise RuntimeError(
152+
"Dependency conflict found: {}".format(pip_check)
153+
)
154+
155+
156+
def _is_installed(library):
157+
return library in sys.modules or pkgutil.find_loader(library) is not None
158+
159+
160+
def _find_installed_libraries():
161+
return {k: v for k, v in instrumentations.items() if _is_installed(k)}
162+
163+
164+
def _run_requirements(packages):
165+
print("\n".join(packages.values()), end="")
166+
167+
168+
def _run_install(packages):
169+
for pkg, inst in packages.items():
170+
_install_package(pkg, inst)
171+
172+
_pip_check()
173+
174+
175+
def run() -> None:
176+
action_install = "install"
177+
action_requirements = "requirements"
178+
179+
parser = argparse.ArgumentParser(
180+
description="""
181+
opentelemetry-bootstrap detects installed libraries and automatically
182+
installs the relevant instrumentation packages for them.
183+
"""
184+
)
185+
parser.add_argument(
186+
"-a",
187+
"--action",
188+
choices=[action_install, action_requirements],
189+
default=action_requirements,
190+
help="""
191+
install - uses pip to install the new requirements using to the
192+
currently active site-package.
193+
requirements - prints out the new requirements to stdout. Action can
194+
be piped and appended to a requirements.txt file.
195+
""",
196+
)
197+
args = parser.parse_args()
198+
199+
cmd = {
200+
action_install: _run_install,
201+
action_requirements: _run_requirements,
202+
}[args.action]
203+
cmd(_find_installed_libraries())
+125
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
# type: ignore
15+
16+
from functools import reduce
17+
from io import StringIO
18+
from random import sample
19+
from unittest import TestCase
20+
from unittest.mock import call, patch
21+
22+
from opentelemetry.auto_instrumentation import bootstrap
23+
24+
25+
def sample_packages(packages, rate):
26+
sampled = sample(list(packages), int(len(packages) * rate),)
27+
return {k: v for k, v in packages.items() if k in sampled}
28+
29+
30+
class TestBootstrap(TestCase):
31+
32+
installed_libraries = {}
33+
installed_instrumentations = {}
34+
35+
@classmethod
36+
def setUpClass(cls):
37+
# select random 60% of instrumentations
38+
cls.installed_libraries = sample_packages(
39+
bootstrap.instrumentations, 0.6
40+
)
41+
42+
# treat 50% of sampled packages as pre-installed
43+
cls.installed_instrumentations = sample_packages(
44+
cls.installed_libraries, 0.5
45+
)
46+
47+
cls.pkg_patcher = patch(
48+
"opentelemetry.auto_instrumentation.bootstrap._find_installed_libraries",
49+
return_value=cls.installed_libraries,
50+
)
51+
52+
pip_freeze_output = []
53+
for inst in cls.installed_instrumentations.values():
54+
inst = inst.replace(">=", "==")
55+
if "==" not in inst:
56+
inst = "{}==x.y".format(inst)
57+
pip_freeze_output.append(inst)
58+
59+
cls.pip_freeze_patcher = patch(
60+
"opentelemetry.auto_instrumentation.bootstrap._sys_pip_freeze",
61+
return_value="\n".join(pip_freeze_output),
62+
)
63+
cls.pip_install_patcher = patch(
64+
"opentelemetry.auto_instrumentation.bootstrap._sys_pip_install",
65+
)
66+
cls.pip_uninstall_patcher = patch(
67+
"opentelemetry.auto_instrumentation.bootstrap._sys_pip_uninstall",
68+
)
69+
cls.pip_check_patcher = patch(
70+
"opentelemetry.auto_instrumentation.bootstrap._pip_check",
71+
)
72+
73+
cls.pkg_patcher.start()
74+
cls.mock_pip_freeze = cls.pip_freeze_patcher.start()
75+
cls.mock_pip_install = cls.pip_install_patcher.start()
76+
cls.mock_pip_uninstall = cls.pip_uninstall_patcher.start()
77+
cls.mock_pip_check = cls.pip_check_patcher.start()
78+
79+
@classmethod
80+
def tearDownClass(cls):
81+
cls.pip_check_patcher.start()
82+
cls.pip_uninstall_patcher.start()
83+
cls.pip_install_patcher.start()
84+
cls.pip_freeze_patcher.start()
85+
cls.pkg_patcher.stop()
86+
87+
@patch("sys.argv", ["bootstrap", "-a", "pipenv"])
88+
def test_run_unknown_cmd(self):
89+
with self.assertRaises(SystemExit):
90+
bootstrap.run()
91+
92+
@patch("sys.argv", ["bootstrap", "-a", "requirements"])
93+
def test_run_cmd_print(self):
94+
with patch("sys.stdout", new=StringIO()) as fake_out:
95+
bootstrap.run()
96+
self.assertEqual(
97+
fake_out.getvalue(),
98+
"\n".join(self.installed_libraries.values()),
99+
)
100+
101+
@patch("sys.argv", ["bootstrap", "-a", "install"])
102+
def test_run_cmd_install(self):
103+
bootstrap.run()
104+
105+
self.assertEqual(
106+
self.mock_pip_freeze.call_count, len(self.installed_libraries)
107+
)
108+
109+
to_uninstall = reduce(
110+
lambda x, y: x + y,
111+
[
112+
pkgs
113+
for lib, pkgs in bootstrap.libraries.items()
114+
if lib in self.installed_instrumentations
115+
],
116+
)
117+
self.mock_pip_uninstall.assert_has_calls(
118+
[call(i) for i in to_uninstall], any_order=True
119+
)
120+
121+
self.mock_pip_install.assert_has_calls(
122+
[call(i) for i in self.installed_libraries.values()],
123+
any_order=True,
124+
)
125+
self.assertEqual(self.mock_pip_check.call_count, 1)

0 commit comments

Comments
 (0)