Skip to content

Commit 6e662f4

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 1240743 commit 6e662f4

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
@@ -96,6 +96,11 @@ envlist =
9696

9797
{py3.6,py3.7,py3.8,py3.9,py3.10}-httpx-{0.16,0.17}
9898

99+
{py2.7,py3.6}-pymongo-{3.1}
100+
{py2.7,py3.6,py3.7,py3.8,py3.9}-pymongo-{3.12}
101+
{py3.6,py3.7,py3.8,py3.9,py3.10}-pymongo-{4.0}
102+
{py3.7,py3.8,py3.9,py3.10}-pymongo-{4.1,4.2}
103+
99104
[testenv]
100105
deps =
101106
# if you change test-requirements.txt and your change is not being reflected
@@ -280,6 +285,13 @@ deps =
280285
httpx-0.16: httpx>=0.16,<0.17
281286
httpx-0.17: httpx>=0.17,<0.18
282287

288+
pymongo: mockupdb
289+
pymongo-3.1: pymongo>=3.1,<3.2
290+
pymongo-3.12: pymongo>=3.12,<4.0
291+
pymongo-4.0: pymongo>=4.0,<4.1
292+
pymongo-4.1: pymongo>=4.1,<4.2
293+
pymongo-4.2: pymongo>=4.2,<4.3
294+
283295
setenv =
284296
PYTHONDONTWRITEBYTECODE=1
285297
TESTPATH=tests
@@ -309,6 +321,7 @@ setenv =
309321
chalice: TESTPATH=tests/integrations/chalice
310322
boto3: TESTPATH=tests/integrations/boto3
311323
httpx: TESTPATH=tests/integrations/httpx
324+
pymongo: TESTPATH=tests/integrations/pymongo
312325

313326
COVERAGE_FILE=.coverage-{envname}
314327
passenv =
@@ -324,6 +337,7 @@ extras =
324337
bottle: bottle
325338
falcon: falcon
326339
quart: quart
340+
pymongo: pymongo
327341

328342
basepython =
329343
py2.7: python2.7

0 commit comments

Comments
 (0)