Skip to content

Commit fa1b964

Browse files
Agalinantonpirker
andauthored
feat(pymongo): add PyMongo integration (#1590)
* 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. Personal identifiable information (PII) will be stripped from all PyMongo commands. (This was tested in the PyMongo versions below, but "should" also be future proof) 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. Co-authored-by: Szymon Soloch <[email protected]> Co-authored-by: Anton Pirker <[email protected]>
1 parent d196a43 commit fa1b964

File tree

7 files changed

+683
-0
lines changed

7 files changed

+683
-0
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: Test pymongo
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- release/**
8+
9+
pull_request:
10+
11+
# Cancel in progress workflows on pull_requests.
12+
# https://docs.github.com/en/actions/using-jobs/using-concurrency#example-using-a-fallback-value
13+
concurrency:
14+
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
15+
cancel-in-progress: true
16+
17+
permissions:
18+
contents: read
19+
20+
env:
21+
BUILD_CACHE_KEY: ${{ github.sha }}
22+
CACHED_BUILD_PATHS: |
23+
${{ github.workspace }}/dist-serverless
24+
25+
jobs:
26+
test:
27+
name: pymongo, python ${{ matrix.python-version }}, ${{ matrix.os }}
28+
runs-on: ${{ matrix.os }}
29+
timeout-minutes: 45
30+
continue-on-error: true
31+
32+
strategy:
33+
matrix:
34+
python-version: ["2.7","3.6","3.7","3.8","3.9","3.10"]
35+
os: [ubuntu-latest]
36+
37+
steps:
38+
- uses: actions/checkout@v3
39+
- uses: actions/setup-python@v4
40+
with:
41+
python-version: ${{ matrix.python-version }}
42+
43+
- name: Setup Test Env
44+
env:
45+
PGHOST: localhost
46+
PGPASSWORD: sentry
47+
run: |
48+
pip install codecov tox
49+
50+
- name: Test pymongo
51+
env:
52+
CI_PYTHON_VERSION: ${{ matrix.python-version }}
53+
timeout-minutes: 45
54+
shell: bash
55+
run: |
56+
set -x # print commands that are executed
57+
coverage erase
58+
59+
./scripts/runtox.sh "${{ matrix.python-version }}-pymongo" --cov=tests --cov=sentry_sdk --cov-report= --cov-branch
60+
coverage combine .coverage*
61+
coverage xml -i
62+
codecov --file coverage.xml

linter-requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ flake8==5.0.4
44
types-certifi
55
types-redis
66
types-setuptools
7+
pymongo # There is no separate types module.
78
flake8-bugbear==22.9.23
89
pep8-naming==0.13.2
910
pre-commit # local linting

sentry_sdk/integrations/pymongo.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
from __future__ import absolute_import
2+
import copy
3+
4+
from sentry_sdk import Hub
5+
from sentry_sdk.hub import _should_send_default_pii
6+
from sentry_sdk.integrations import DidNotEnable, Integration
7+
from sentry_sdk.tracing import Span
8+
from sentry_sdk.utils import capture_internal_exceptions
9+
10+
from sentry_sdk._types import MYPY
11+
12+
try:
13+
from pymongo import monitoring
14+
except ImportError:
15+
raise DidNotEnable("Pymongo not installed")
16+
17+
if MYPY:
18+
from typing import Any, Dict, Union
19+
20+
from pymongo.monitoring import (
21+
CommandFailedEvent,
22+
CommandStartedEvent,
23+
CommandSucceededEvent,
24+
)
25+
26+
27+
SAFE_COMMAND_ATTRIBUTES = [
28+
"insert",
29+
"ordered",
30+
"find",
31+
"limit",
32+
"singleBatch",
33+
"aggregate",
34+
"createIndexes",
35+
"indexes",
36+
"delete",
37+
"findAndModify",
38+
"renameCollection",
39+
"to",
40+
"drop",
41+
]
42+
43+
44+
def _strip_pii(command):
45+
# type: (Dict[str, Any]) -> Dict[str, Any]
46+
for key in command:
47+
is_safe_field = key in SAFE_COMMAND_ATTRIBUTES
48+
if is_safe_field:
49+
# Skip if safe key
50+
continue
51+
52+
update_db_command = key == "update" and "findAndModify" not in command
53+
if update_db_command:
54+
# Also skip "update" db command because it is save.
55+
# There is also an "update" key in the "findAndModify" command, which is NOT safe!
56+
continue
57+
58+
# Special stripping for documents
59+
is_document = key == "documents"
60+
if is_document:
61+
for doc in command[key]:
62+
for doc_key in doc:
63+
doc[doc_key] = "%s"
64+
continue
65+
66+
# Special stripping for dict style fields
67+
is_dict_field = key in ["filter", "query", "update"]
68+
if is_dict_field:
69+
for item_key in command[key]:
70+
command[key][item_key] = "%s"
71+
continue
72+
73+
# For pipeline fields strip the `$match` dict
74+
is_pipeline_field = key == "pipeline"
75+
if is_pipeline_field:
76+
for pipeline in command[key]:
77+
for match_key in pipeline["$match"] if "$match" in pipeline else []:
78+
pipeline["$match"][match_key] = "%s"
79+
continue
80+
81+
# Default stripping
82+
command[key] = "%s"
83+
84+
return command
85+
86+
87+
class CommandTracer(monitoring.CommandListener):
88+
def __init__(self):
89+
# type: () -> None
90+
self._ongoing_operations = {} # type: Dict[int, Span]
91+
92+
def _operation_key(self, event):
93+
# type: (Union[CommandFailedEvent, CommandStartedEvent, CommandSucceededEvent]) -> int
94+
return event.request_id
95+
96+
def started(self, event):
97+
# type: (CommandStartedEvent) -> None
98+
hub = Hub.current
99+
if hub.get_integration(PyMongoIntegration) is None:
100+
return
101+
with capture_internal_exceptions():
102+
command = dict(copy.deepcopy(event.command))
103+
104+
command.pop("$db", None)
105+
command.pop("$clusterTime", None)
106+
command.pop("$signature", None)
107+
108+
op = "db.query"
109+
110+
tags = {
111+
"db.name": event.database_name,
112+
"db.system": "mongodb",
113+
"db.operation": event.command_name,
114+
}
115+
116+
try:
117+
tags["net.peer.name"] = event.connection_id[0]
118+
tags["net.peer.port"] = str(event.connection_id[1])
119+
except TypeError:
120+
pass
121+
122+
data = {"operation_ids": {}} # type: Dict[str, Dict[str, Any]]
123+
124+
data["operation_ids"]["operation"] = event.operation_id
125+
data["operation_ids"]["request"] = event.request_id
126+
127+
try:
128+
lsid = command.pop("lsid")["id"]
129+
data["operation_ids"]["session"] = str(lsid)
130+
except KeyError:
131+
pass
132+
133+
if not _should_send_default_pii():
134+
command = _strip_pii(command)
135+
136+
query = "{} {}".format(event.command_name, command)
137+
span = hub.start_span(op=op, description=query)
138+
139+
for tag, value in tags.items():
140+
span.set_tag(tag, value)
141+
142+
for key, value in data.items():
143+
span.set_data(key, value)
144+
145+
with capture_internal_exceptions():
146+
hub.add_breadcrumb(message=query, category="query", type=op, data=tags)
147+
148+
self._ongoing_operations[self._operation_key(event)] = span.__enter__()
149+
150+
def failed(self, event):
151+
# type: (CommandFailedEvent) -> None
152+
hub = Hub.current
153+
if hub.get_integration(PyMongoIntegration) is None:
154+
return
155+
156+
try:
157+
span = self._ongoing_operations.pop(self._operation_key(event))
158+
span.set_status("internal_error")
159+
span.__exit__(None, None, None)
160+
except KeyError:
161+
return
162+
163+
def succeeded(self, event):
164+
# type: (CommandSucceededEvent) -> None
165+
hub = Hub.current
166+
if hub.get_integration(PyMongoIntegration) is None:
167+
return
168+
169+
try:
170+
span = self._ongoing_operations.pop(self._operation_key(event))
171+
span.set_status("ok")
172+
span.__exit__(None, None, None)
173+
except KeyError:
174+
pass
175+
176+
177+
class PyMongoIntegration(Integration):
178+
identifier = "pymongo"
179+
180+
@staticmethod
181+
def setup_once():
182+
# type: () -> None
183+
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")

0 commit comments

Comments
 (0)