Skip to content

Commit 3d2517d

Browse files
authored
Allow to use OTel for performance instrumentation (experimental) (#2272)
To enable this experimental feature, install `sentry_sdk[opentelemetry-experimental]` and initialize the SDK with `_experiments={"otel_powered_performance": True}`. This sets up performance powered by OTel for a handful of the most popular Python frameworks/libraries like Django, Flask, FastAPI, requests. Note that this is a proof of concept which we might end up utilizing or not -- depending on how successful this attempt is at addressing the various issues we've identified with regards to our compatibility with OTel. As the goal was to make this work automatically without requiring the user to set anything up, the autoinstrumentation builds on what the official opentelemetry-instrument tool does, but without having to actually use it to run a program (opentelemetry-instrument python app.py).
1 parent bd34437 commit 3d2517d

File tree

9 files changed

+312
-55
lines changed

9 files changed

+312
-55
lines changed

sentry_sdk/client.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
VERSION,
2828
ClientConstructor,
2929
)
30-
from sentry_sdk.integrations import setup_integrations
30+
from sentry_sdk.integrations import _DEFAULT_INTEGRATIONS, setup_integrations
3131
from sentry_sdk.utils import ContextVar
3232
from sentry_sdk.sessions import SessionFlusher
3333
from sentry_sdk.envelope import Envelope
@@ -237,6 +237,15 @@ def _capture_envelope(envelope):
237237
)
238238
)
239239

240+
if self.options["_experiments"].get("otel_powered_performance", False):
241+
logger.debug(
242+
"[OTel] Enabling experimental OTel-powered performance monitoring."
243+
)
244+
self.options["instrumenter"] = INSTRUMENTER.OTEL
245+
_DEFAULT_INTEGRATIONS.append(
246+
"sentry_sdk.integrations.opentelemetry.OpenTelemetryIntegration",
247+
)
248+
240249
self.integrations = setup_integrations(
241250
self.options["integrations"],
242251
with_defaults=self.options["default_integrations"],

sentry_sdk/consts.py

+1
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
# TODO: Remove these 2 profiling related experiments
4040
"profiles_sample_rate": Optional[float],
4141
"profiler_mode": Optional[ProfilerMode],
42+
"otel_powered_performance": Optional[bool],
4243
},
4344
total=False,
4445
)

sentry_sdk/integrations/__init__.py

+37-33
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
1-
"""This package"""
21
from __future__ import absolute_import
3-
42
from threading import Lock
53

64
from sentry_sdk._compat import iteritems
5+
from sentry_sdk._types import TYPE_CHECKING
76
from sentry_sdk.utils import logger
87

9-
from sentry_sdk._types import TYPE_CHECKING
108

119
if TYPE_CHECKING:
1210
from typing import Callable
1311
from typing import Dict
1412
from typing import Iterator
1513
from typing import List
1614
from typing import Set
17-
from typing import Tuple
1815
from typing import Type
1916

2017

2118
_installer_lock = Lock()
2219
_installed_integrations = set() # type: Set[str]
2320

2421

25-
def _generate_default_integrations_iterator(integrations, auto_enabling_integrations):
26-
# type: (Tuple[str, ...], Tuple[str, ...]) -> Callable[[bool], Iterator[Type[Integration]]]
22+
def _generate_default_integrations_iterator(
23+
integrations, # type: List[str]
24+
auto_enabling_integrations, # type: List[str]
25+
):
26+
# type: (...) -> Callable[[bool], Iterator[Type[Integration]]]
2727

2828
def iter_default_integrations(with_auto_enabling_integrations):
2929
# type: (bool) -> Iterator[Type[Integration]]
@@ -51,38 +51,40 @@ def iter_default_integrations(with_auto_enabling_integrations):
5151
return iter_default_integrations
5252

5353

54-
_AUTO_ENABLING_INTEGRATIONS = (
55-
"sentry_sdk.integrations.django.DjangoIntegration",
56-
"sentry_sdk.integrations.flask.FlaskIntegration",
57-
"sentry_sdk.integrations.starlette.StarletteIntegration",
58-
"sentry_sdk.integrations.fastapi.FastApiIntegration",
54+
_DEFAULT_INTEGRATIONS = [
55+
# stdlib/base runtime integrations
56+
"sentry_sdk.integrations.argv.ArgvIntegration",
57+
"sentry_sdk.integrations.atexit.AtexitIntegration",
58+
"sentry_sdk.integrations.dedupe.DedupeIntegration",
59+
"sentry_sdk.integrations.excepthook.ExcepthookIntegration",
60+
"sentry_sdk.integrations.logging.LoggingIntegration",
61+
"sentry_sdk.integrations.modules.ModulesIntegration",
62+
"sentry_sdk.integrations.stdlib.StdlibIntegration",
63+
"sentry_sdk.integrations.threading.ThreadingIntegration",
64+
]
65+
66+
_AUTO_ENABLING_INTEGRATIONS = [
67+
"sentry_sdk.integrations.aiohttp.AioHttpIntegration",
68+
"sentry_sdk.integrations.boto3.Boto3Integration",
5969
"sentry_sdk.integrations.bottle.BottleIntegration",
60-
"sentry_sdk.integrations.falcon.FalconIntegration",
61-
"sentry_sdk.integrations.sanic.SanicIntegration",
6270
"sentry_sdk.integrations.celery.CeleryIntegration",
71+
"sentry_sdk.integrations.django.DjangoIntegration",
72+
"sentry_sdk.integrations.falcon.FalconIntegration",
73+
"sentry_sdk.integrations.fastapi.FastApiIntegration",
74+
"sentry_sdk.integrations.flask.FlaskIntegration",
75+
"sentry_sdk.integrations.httpx.HttpxIntegration",
76+
"sentry_sdk.integrations.pyramid.PyramidIntegration",
77+
"sentry_sdk.integrations.redis.RedisIntegration",
6378
"sentry_sdk.integrations.rq.RqIntegration",
64-
"sentry_sdk.integrations.aiohttp.AioHttpIntegration",
65-
"sentry_sdk.integrations.tornado.TornadoIntegration",
79+
"sentry_sdk.integrations.sanic.SanicIntegration",
6680
"sentry_sdk.integrations.sqlalchemy.SqlalchemyIntegration",
67-
"sentry_sdk.integrations.redis.RedisIntegration",
68-
"sentry_sdk.integrations.pyramid.PyramidIntegration",
69-
"sentry_sdk.integrations.boto3.Boto3Integration",
70-
"sentry_sdk.integrations.httpx.HttpxIntegration",
71-
)
81+
"sentry_sdk.integrations.starlette.StarletteIntegration",
82+
"sentry_sdk.integrations.tornado.TornadoIntegration",
83+
]
7284

7385

7486
iter_default_integrations = _generate_default_integrations_iterator(
75-
integrations=(
76-
# stdlib/base runtime integrations
77-
"sentry_sdk.integrations.logging.LoggingIntegration",
78-
"sentry_sdk.integrations.stdlib.StdlibIntegration",
79-
"sentry_sdk.integrations.excepthook.ExcepthookIntegration",
80-
"sentry_sdk.integrations.dedupe.DedupeIntegration",
81-
"sentry_sdk.integrations.atexit.AtexitIntegration",
82-
"sentry_sdk.integrations.modules.ModulesIntegration",
83-
"sentry_sdk.integrations.argv.ArgvIntegration",
84-
"sentry_sdk.integrations.threading.ThreadingIntegration",
85-
),
87+
integrations=_DEFAULT_INTEGRATIONS,
8688
auto_enabling_integrations=_AUTO_ENABLING_INTEGRATIONS,
8789
)
8890

@@ -93,8 +95,10 @@ def setup_integrations(
9395
integrations, with_defaults=True, with_auto_enabling_integrations=False
9496
):
9597
# type: (List[Integration], bool, bool) -> Dict[str, Integration]
96-
"""Given a list of integration instances this installs them all. When
97-
`with_defaults` is set to `True` then all default integrations are added
98+
"""
99+
Given a list of integration instances, this installs them all.
100+
101+
When `with_defaults` is set to `True` all default integrations are added
98102
unless they were already provided before.
99103
"""
100104
integrations = dict(

sentry_sdk/integrations/opentelemetry/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
from sentry_sdk.integrations.opentelemetry.integration import ( # noqa: F401
2+
OpenTelemetryIntegration,
3+
)
4+
15
from sentry_sdk.integrations.opentelemetry.span_processor import ( # noqa: F401
26
SentrySpanProcessor,
37
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""
2+
IMPORTANT: The contents of this file are part of a proof of concept and as such
3+
are experimental and not suitable for production use. They may be changed or
4+
removed at any time without prior notice.
5+
"""
6+
import sys
7+
from importlib import import_module
8+
9+
from sentry_sdk.integrations import DidNotEnable, Integration
10+
from sentry_sdk.integrations.opentelemetry.span_processor import SentrySpanProcessor
11+
from sentry_sdk.integrations.opentelemetry.propagator import SentryPropagator
12+
from sentry_sdk.integrations.modules import _get_installed_modules
13+
from sentry_sdk.utils import logger
14+
from sentry_sdk._types import TYPE_CHECKING
15+
16+
try:
17+
from opentelemetry import trace # type: ignore
18+
from opentelemetry.instrumentation.auto_instrumentation._load import ( # type: ignore
19+
_load_distro,
20+
_load_instrumentors,
21+
)
22+
from opentelemetry.propagate import set_global_textmap # type: ignore
23+
from opentelemetry.sdk.trace import TracerProvider # type: ignore
24+
except ImportError:
25+
raise DidNotEnable("opentelemetry not installed")
26+
27+
if TYPE_CHECKING:
28+
from typing import Dict
29+
30+
31+
CLASSES_TO_INSTRUMENT = {
32+
# A mapping of packages to their entry point class that will be instrumented.
33+
# This is used to post-instrument any classes that were imported before OTel
34+
# instrumentation took place.
35+
"fastapi": "fastapi.FastAPI",
36+
"flask": "flask.Flask",
37+
}
38+
39+
40+
class OpenTelemetryIntegration(Integration):
41+
identifier = "opentelemetry"
42+
43+
@staticmethod
44+
def setup_once():
45+
# type: () -> None
46+
logger.warning(
47+
"[OTel] Initializing highly experimental OpenTelemetry support. "
48+
"Use at your own risk."
49+
)
50+
51+
original_classes = _record_unpatched_classes()
52+
53+
try:
54+
distro = _load_distro()
55+
distro.configure()
56+
_load_instrumentors(distro)
57+
except Exception:
58+
logger.exception("[OTel] Failed to auto-initialize OpenTelemetry")
59+
60+
try:
61+
_patch_remaining_classes(original_classes)
62+
except Exception:
63+
logger.exception(
64+
"[OTel] Failed to post-patch instrumented classes. "
65+
"You might have to make sure sentry_sdk.init() is called before importing anything else."
66+
)
67+
68+
_setup_sentry_tracing()
69+
70+
logger.debug("[OTel] Finished setting up OpenTelemetry integration")
71+
72+
73+
def _record_unpatched_classes():
74+
# type: () -> Dict[str, type]
75+
"""
76+
Keep references to classes that are about to be instrumented.
77+
78+
Used to search for unpatched classes after the instrumentation has run so
79+
that they can be patched manually.
80+
"""
81+
installed_packages = _get_installed_modules()
82+
83+
original_classes = {}
84+
85+
for package, orig_path in CLASSES_TO_INSTRUMENT.items():
86+
if package in installed_packages:
87+
try:
88+
original_cls = _import_by_path(orig_path)
89+
except (AttributeError, ImportError):
90+
logger.debug("[OTel] Failed to import %s", orig_path)
91+
continue
92+
93+
original_classes[package] = original_cls
94+
95+
return original_classes
96+
97+
98+
def _patch_remaining_classes(original_classes):
99+
# type: (Dict[str, type]) -> None
100+
"""
101+
Best-effort attempt to patch any uninstrumented classes in sys.modules.
102+
103+
This enables us to not care about the order of imports and sentry_sdk.init()
104+
in user code. If e.g. the Flask class had been imported before sentry_sdk
105+
was init()ed (and therefore before the OTel instrumentation ran), it would
106+
not be instrumented. This function goes over remaining uninstrumented
107+
occurrences of the class in sys.modules and replaces them with the
108+
instrumented class.
109+
110+
Since this is looking for exact matches, it will not work in some scenarios
111+
(e.g. if someone is not using the specific class explicitly, but rather
112+
inheriting from it). In those cases it's still necessary to sentry_sdk.init()
113+
before importing anything that's supposed to be instrumented.
114+
"""
115+
# check which classes have actually been instrumented
116+
instrumented_classes = {}
117+
118+
for package in list(original_classes.keys()):
119+
original_path = CLASSES_TO_INSTRUMENT[package]
120+
121+
try:
122+
cls = _import_by_path(original_path)
123+
except (AttributeError, ImportError):
124+
logger.debug(
125+
"[OTel] Failed to check if class has been instrumented: %s",
126+
original_path,
127+
)
128+
del original_classes[package]
129+
continue
130+
131+
if not cls.__module__.startswith("opentelemetry."):
132+
del original_classes[package]
133+
continue
134+
135+
instrumented_classes[package] = cls
136+
137+
if not instrumented_classes:
138+
return
139+
140+
# replace occurrences of the original unpatched class in sys.modules
141+
for module_name, module in sys.modules.copy().items():
142+
if (
143+
module_name.startswith("sentry_sdk")
144+
or module_name in sys.builtin_module_names
145+
):
146+
continue
147+
148+
for package, original_cls in original_classes.items():
149+
for var_name, var in vars(module).copy().items():
150+
if var == original_cls:
151+
logger.debug(
152+
"[OTel] Additionally patching %s from %s",
153+
original_cls,
154+
module_name,
155+
)
156+
157+
setattr(module, var_name, instrumented_classes[package])
158+
159+
160+
def _import_by_path(path):
161+
# type: (str) -> type
162+
parts = path.rsplit(".", maxsplit=1)
163+
return getattr(import_module(parts[0]), parts[-1])
164+
165+
166+
def _setup_sentry_tracing():
167+
# type: () -> None
168+
provider = TracerProvider()
169+
170+
provider.add_span_processor(SentrySpanProcessor())
171+
172+
trace.set_tracer_provider(provider)
173+
174+
set_global_textmap(SentryPropagator())

sentry_sdk/integrations/opentelemetry/span_processor.py

+19
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ def on_end(self, otel_span):
169169
sentry_span.set_context(
170170
OPEN_TELEMETRY_CONTEXT, self._get_otel_context(otel_span)
171171
)
172+
self._update_transaction_with_otel_data(sentry_span, otel_span)
172173

173174
else:
174175
self._update_span_with_otel_data(sentry_span, otel_span)
@@ -306,3 +307,21 @@ def _update_span_with_otel_data(self, sentry_span, otel_span):
306307

307308
sentry_span.op = op
308309
sentry_span.description = description
310+
311+
def _update_transaction_with_otel_data(self, sentry_span, otel_span):
312+
# type: (SentrySpan, OTelSpan) -> None
313+
http_method = otel_span.attributes.get(SpanAttributes.HTTP_METHOD)
314+
315+
if http_method:
316+
status_code = otel_span.attributes.get(SpanAttributes.HTTP_STATUS_CODE)
317+
if status_code:
318+
sentry_span.set_http_status(status_code)
319+
320+
op = "http"
321+
322+
if otel_span.kind == SpanKind.SERVER:
323+
op += ".server"
324+
elif otel_span.kind == SpanKind.CLIENT:
325+
op += ".client"
326+
327+
sentry_span.op = op

0 commit comments

Comments
 (0)