Skip to content

Commit 390fa8c

Browse files
committed
feat(pymongo): add PyMongo integration
Adds breadcrumbs and performance traces for PyMongo queries using an official monitoring API. Integration is similar to the one available in OpenTelemetry, tags set to values recommended for attributes by OT as specified in `Span Operations` guidelines. PyMongo version selection explanation: * 3.1 - introduction of monitoring API. Only Python 2.7 and 3.6 supported. * 3.12 - latest 3.x release, support for 2.7, 3.6-3.9 (3.7-3.9 added in various minor releases between 3.1 and 3.12). * 4.0 - no support for 2.7, added support for 3.10. * 4.1 - no support for 3.6.0-3.6.1. * 4.2 - no support for any 3.6.
1 parent fa4f5b0 commit 390fa8c

File tree

5 files changed

+234
-0
lines changed

5 files changed

+234
-0
lines changed

sentry_sdk/integrations/pymongo.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
from __future__ import absolute_import
2+
3+
from sentry_sdk import Hub
4+
from sentry_sdk.integrations import DidNotEnable, Integration
5+
from sentry_sdk.tracing import Span
6+
from sentry_sdk.utils import capture_internal_exceptions
7+
8+
from sentry_sdk._types import MYPY
9+
10+
try:
11+
from pymongo import monitoring
12+
except ImportError:
13+
raise DidNotEnable("Pymongo not installed")
14+
15+
if MYPY:
16+
from typing import Dict
17+
18+
from pymongo.monitoring import (
19+
CommandFailedEvent,
20+
CommandStartedEvent,
21+
CommandSucceededEvent,
22+
)
23+
24+
25+
class CommandTracer(monitoring.CommandListener):
26+
def __init__(self):
27+
self._ongoing_operations = {} # type: Dict[int, Span]
28+
29+
def _operation_key(self, event):
30+
# type: (CommandFailedEvent | CommandStartedEvent | CommandSucceededEvent) -> int
31+
return event.request_id
32+
33+
def started(self, event):
34+
# type: (CommandStartedEvent) -> None
35+
hub = Hub.current
36+
if hub.get_integration(PyMongoIntegration) is None:
37+
return
38+
with capture_internal_exceptions():
39+
command = dict(event.command)
40+
41+
command.pop("$db", None)
42+
command.pop("$clusterTime", None)
43+
command.pop("$signature", None)
44+
45+
if event.command_name:
46+
op = "db." + event.command_name
47+
else:
48+
op = "db"
49+
50+
tags = {
51+
"db.name": event.database_name,
52+
"db.system": "mongodb",
53+
"db.operation": event.command_name,
54+
}
55+
56+
try:
57+
tags["net.peer.name"] = event.connection_id[0]
58+
tags["net.peer.port"] = str(event.connection_id[1])
59+
except TypeError:
60+
pass
61+
62+
data = {"operation_ids": {}}
63+
64+
data["operation_ids"]["operation"] = event.operation_id
65+
data["operation_ids"]["request"] = event.request_id
66+
67+
try:
68+
lsid = command.pop("lsid")["id"]
69+
data["operation_ids"]["session"] = str(lsid)
70+
except KeyError:
71+
pass
72+
73+
query = "{} {}".format(event.command_name, command)
74+
span = hub.start_span(op=op, description=query)
75+
76+
for tag, value in tags.items():
77+
span.set_tag(tag, value)
78+
79+
for key, value in data.items():
80+
span.set_data(key, value)
81+
82+
with capture_internal_exceptions():
83+
hub.add_breadcrumb(message=query, category="query", type=op, data=tags)
84+
85+
self._ongoing_operations[self._operation_key(event)] = span.__enter__()
86+
87+
def failed(self, event):
88+
# type: (CommandFailedEvent) -> None
89+
hub = Hub.current
90+
if hub.get_integration(PyMongoIntegration) is None:
91+
return
92+
93+
try:
94+
span = self._ongoing_operations.pop(self._operation_key(event))
95+
span.set_status("internal_error")
96+
span.__exit__(None, None, None)
97+
except KeyError:
98+
return
99+
100+
def succeeded(self, event):
101+
# type: (CommandSucceededEvent) -> None
102+
hub = Hub.current
103+
if hub.get_integration(PyMongoIntegration) is None:
104+
return
105+
106+
try:
107+
span = self._ongoing_operations.pop(self._operation_key(event))
108+
span.set_status("ok")
109+
span.__exit__(None, None, None)
110+
except KeyError:
111+
pass
112+
113+
114+
class PyMongoIntegration(Integration):
115+
identifier = "pymongo"
116+
117+
@staticmethod
118+
def setup_once():
119+
# type: () -> None
120+
monitoring.register(CommandTracer())

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def get_file_text(file_name):
6262
"httpx": ["httpx>=0.16.0"],
6363
"starlette": ["starlette>=0.19.1"],
6464
"fastapi": ["fastapi>=0.79.0"],
65+
"pymongo": ["pymongo>=3.1"],
6566
},
6667
classifiers=[
6768
"Development Status :: 5 - Production/Stable",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import pytest
2+
3+
pytest.importorskip("pymongo")
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from sentry_sdk import capture_message, start_transaction
2+
from sentry_sdk.integrations.pymongo import PyMongoIntegration
3+
4+
from mockupdb import MockupDB, OpQuery
5+
from pymongo import MongoClient
6+
import pytest
7+
8+
9+
@pytest.fixture(scope="session")
10+
def mongo_server():
11+
server = MockupDB(verbose=True)
12+
server.autoresponds("ismaster", maxWireVersion=6)
13+
server.run()
14+
server.autoresponds(
15+
{"find": "test_collection"}, cursor={"id": 123, "firstBatch": []}
16+
)
17+
# Find query changed somewhere between PyMongo 3.1 and 3.12.
18+
# This line is to respond to "find" queries sent by old PyMongo the same way it's done above.
19+
server.autoresponds(OpQuery({"foobar": 1}), cursor={"id": 123, "firstBatch": []})
20+
server.autoresponds({"insert": "test_collection"}, ok=1)
21+
server.autoresponds({"insert": "erroneous"}, ok=0, errmsg="test error")
22+
yield server
23+
server.stop()
24+
25+
26+
def test_transactions(sentry_init, capture_events, mongo_server):
27+
sentry_init(integrations=[PyMongoIntegration()], traces_sample_rate=1.0)
28+
events = capture_events()
29+
30+
connection = MongoClient(mongo_server.uri)
31+
32+
with start_transaction():
33+
list(
34+
connection["test_db"]["test_collection"].find({"foobar": 1})
35+
) # force query execution
36+
connection["test_db"]["test_collection"].insert_one({"foo": 2})
37+
try:
38+
connection["test_db"]["erroneous"].insert_many([{"bar": 3}, {"baz": 3}])
39+
pytest.fail("Request should raise")
40+
except Exception:
41+
pass
42+
43+
(event,) = events
44+
(find, insert_success, insert_fail) = event["spans"]
45+
46+
common_tags = {
47+
"db.name": "test_db",
48+
"db.system": "mongodb",
49+
"net.peer.name": mongo_server.host,
50+
"net.peer.port": str(mongo_server.port),
51+
}
52+
for span in find, insert_success, insert_fail:
53+
for field, value in common_tags.items():
54+
assert span["tags"][field] == value
55+
56+
assert find["op"] == "db.find"
57+
assert insert_success["op"] == "db.insert"
58+
assert insert_fail["op"] == "db.insert"
59+
60+
assert find["tags"]["db.operation"] == "find"
61+
assert insert_success["tags"]["db.operation"] == "insert"
62+
assert insert_fail["tags"]["db.operation"] == "insert"
63+
64+
assert find["description"].startswith("find {")
65+
assert insert_success["description"].startswith("insert {")
66+
assert insert_fail["description"].startswith("insert {")
67+
68+
assert find["tags"]["status"] == "ok"
69+
assert insert_success["tags"]["status"] == "ok"
70+
assert insert_fail["tags"]["status"] == "internal_error"
71+
72+
73+
def test_breadcrumbs(sentry_init, capture_events, mongo_server):
74+
sentry_init(integrations=[PyMongoIntegration()], traces_sample_rate=1.0)
75+
events = capture_events()
76+
77+
connection = MongoClient(mongo_server.uri)
78+
79+
list(
80+
connection["test_db"]["test_collection"].find({"foobar": 1})
81+
) # force query execution
82+
capture_message("hi")
83+
84+
(event,) = events
85+
(crumb,) = event["breadcrumbs"]["values"]
86+
87+
assert crumb["category"] == "query"
88+
assert crumb["message"].startswith("find {")
89+
assert crumb["type"] == "db.find"
90+
assert crumb["data"] == {
91+
"db.name": "test_db",
92+
"db.system": "mongodb",
93+
"db.operation": "find",
94+
"net.peer.name": mongo_server.host,
95+
"net.peer.port": str(mongo_server.port),
96+
}

tox.ini

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,11 @@ envlist =
8989

9090
{py3.6,py3.7,py3.8,py3.9,py3.10}-httpx-{0.16,0.17}
9191

92+
{py2.7,py3.6}-pymongo-{3.1}
93+
{py2.7,py3.6,py3.7,py3.8,py3.9}-pymongo-{3.12}
94+
{py3.6,py3.7,py3.8,py3.9,py3.10}-pymongo-{4.0}
95+
{py3.7,py3.8,py3.9,py3.10}-pymongo-{4.1,4.2}
96+
9297
[testenv]
9398
deps =
9499
# if you change test-requirements.txt and your change is not being reflected
@@ -257,6 +262,13 @@ deps =
257262
httpx-0.16: httpx>=0.16,<0.17
258263
httpx-0.17: httpx>=0.17,<0.18
259264

265+
pymongo: mockupdb
266+
pymongo-3.1: pymongo>=3.1,<3.2
267+
pymongo-3.12: pymongo>=3.12,<4.0
268+
pymongo-4.0: pymongo>=4.0,<4.1
269+
pymongo-4.1: pymongo>=4.1,<4.2
270+
pymongo-4.2: pymongo>=4.2,<4.3
271+
260272
setenv =
261273
PYTHONDONTWRITEBYTECODE=1
262274
TESTPATH=tests
@@ -286,6 +298,7 @@ setenv =
286298
chalice: TESTPATH=tests/integrations/chalice
287299
boto3: TESTPATH=tests/integrations/boto3
288300
httpx: TESTPATH=tests/integrations/httpx
301+
pymongo: TESTPATH=tests/integrations/pymongo
289302

290303
COVERAGE_FILE=.coverage-{envname}
291304
passenv =
@@ -301,6 +314,7 @@ extras =
301314
bottle: bottle
302315
falcon: falcon
303316
quart: quart
317+
pymongo: pymongo
304318

305319
basepython =
306320
py2.7: python2.7

0 commit comments

Comments
 (0)