Skip to content

Commit def1ec7

Browse files
Add Motor Instrumentation (#1255)
* Add motor to tox * Add tests for motor * Add instrumentation for motor * Linting * Add comment to explain instance info * Update mongodb in CI * Update mongodb in CI * Split mongodb CI into v3 and v8 * Fix pymongo metric for py37 * Guard attr access --------- Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
1 parent 0867109 commit def1ec7

File tree

6 files changed

+487
-22
lines changed

6 files changed

+487
-22
lines changed

newrelic/config.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3742,7 +3742,12 @@ def _process_module_builtin_defaults():
37423742
"valkey.commands.graph.commands", "newrelic.hooks.datastore_valkey", "instrument_valkey_commands_graph_commands"
37433743
)
37443744

3745-
_process_module_definition("motor", "newrelic.hooks.datastore_motor", "patch_motor")
3745+
_process_module_definition(
3746+
"motor.motor_asyncio", "newrelic.hooks.datastore_motor", "instrument_motor_motor_asyncio"
3747+
)
3748+
_process_module_definition(
3749+
"motor.motor_tornado", "newrelic.hooks.datastore_motor", "instrument_motor_motor_tornado"
3750+
)
37463751

37473752
_process_module_definition(
37483753
"piston.resource",

newrelic/hooks/datastore_motor.py

Lines changed: 120 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,35 +12,134 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from newrelic.api.datastore_trace import DatastoreTrace
16+
from newrelic.api.function_trace import wrap_function_trace
1517
from newrelic.common.object_wrapper import wrap_function_wrapper
1618

17-
# This is NOT a fully-featured instrumentation for the motor library. Instead
18-
# this is a monkey-patch of the motor library to work around a bug that causes
19-
# the __name__ lookup on a MotorCollection object to fail. This bug was causing
20-
# customer's applications to fail when they used motor in Tornado applications.
19+
_motor_client_sync_methods = (
20+
"aggregate_raw_batches",
21+
"aggregate",
22+
"find_raw_batches",
23+
"find",
24+
"list_indexes",
25+
"list_search_indexes",
26+
"watch",
27+
)
2128

29+
_motor_client_async_methods = (
30+
"bulk_write",
31+
"count_documents",
32+
"create_index",
33+
"create_indexes",
34+
"create_search_index",
35+
"create_search_indexes",
36+
"delete_many",
37+
"delete_one",
38+
"distinct",
39+
"drop_index",
40+
"drop_indexes",
41+
"drop_search_index",
42+
"drop",
43+
"estimated_document_count",
44+
"find_one_and_delete",
45+
"find_one_and_replace",
46+
"find_one_and_update",
47+
"find_one",
48+
"index_information",
49+
"insert_many",
50+
"insert_one",
51+
"options",
52+
"rename",
53+
"replace_one",
54+
"update_many",
55+
"update_one",
56+
"update_search_index",
57+
)
2258

23-
def _nr_wrapper_Motor_getattr_(wrapped, instance, args, kwargs):
2459

25-
def _bind_params(name, *args, **kwargs):
26-
return name
60+
def instance_info(collection):
61+
try:
62+
nodes = collection.database.client.nodes
63+
if len(nodes) == 1:
64+
return next(iter(nodes))
65+
except Exception:
66+
pass
2767

28-
name = _bind_params(*args, **kwargs)
68+
# If there are 0 nodes we're not currently connected, return nothing.
69+
# If there are 2+ nodes we're in a load balancing setup.
70+
# Unfortunately we can't rely on a deeper method to determine the actual server we're connected to in all cases.
71+
# We can't report more than 1 server for instance info, so we opt here to ignore reporting the host/port and
72+
# leave it empty to avoid confusing customers by guessing and potentially reporting the wrong server.
73+
return None, None
2974

30-
if name.startswith('__') or name.startswith('_nr_'):
31-
raise AttributeError(f'{instance.__class__.__name__} class has no attribute {name}. To access use object[{name!r}].')
3275

33-
return wrapped(*args, **kwargs)
76+
def wrap_motor_method(module, class_name, method_name, is_async=False):
77+
cls = getattr(module, class_name)
78+
if not hasattr(cls, method_name):
79+
return
3480

81+
# Define wrappers as closures to preserve method_name
82+
def _wrap_motor_method_sync(wrapped, instance, args, kwargs):
83+
target = getattr(instance, "name", None)
84+
database_name = getattr(getattr(instance, "database", None), "name", None)
85+
with DatastoreTrace(
86+
product="MongoDB", target=target, operation=method_name, database_name=database_name
87+
) as trace:
88+
response = wrapped(*args, **kwargs)
3589

36-
def patch_motor(module):
37-
if (hasattr(module, 'version_tuple') and
38-
module.version_tuple >= (0, 6)):
39-
return
90+
# Gather instance info after response to ensure client is conncected
91+
address = instance_info(instance)
92+
trace.host = address[0]
93+
trace.port_path_or_id = address[1]
94+
95+
return response
96+
97+
async def _wrap_motor_method_async(wrapped, instance, args, kwargs):
98+
target = getattr(instance, "name", None)
99+
database_name = getattr(getattr(instance, "database", None), "name", None)
100+
with DatastoreTrace(
101+
product="MongoDB", target=target, operation=method_name, database_name=database_name
102+
) as trace:
103+
response = await wrapped(*args, **kwargs)
104+
105+
# Gather instance info after response to ensure client is conncected
106+
address = instance_info(instance)
107+
trace.host = address[0]
108+
trace.port_path_or_id = address[1]
109+
110+
return response
111+
112+
wrapper = _wrap_motor_method_async if is_async else _wrap_motor_method_sync
113+
wrap_function_wrapper(module, f"{class_name}.{method_name}", wrapper)
114+
115+
116+
def instrument_motor_motor_asyncio(module):
117+
if hasattr(module, "AsyncIOMotorClient"):
118+
rollup = ("Datastore/all", "Datastore/MongoDB/all")
119+
# Name function explicitly as motor and pymongo have a history of overriding the
120+
# __getattr__() method in a way that breaks introspection.
121+
wrap_function_trace(
122+
module, "AsyncIOMotorClient.__init__", name=f"{module.__name__}:AsyncIOMotorClient.__init__", rollup=rollup
123+
)
124+
125+
if hasattr(module, "AsyncIOMotorCollection"):
126+
for method_name in _motor_client_sync_methods:
127+
wrap_motor_method(module, "AsyncIOMotorCollection", method_name, is_async=False)
128+
for method_name in _motor_client_async_methods:
129+
wrap_motor_method(module, "AsyncIOMotorCollection", method_name, is_async=True)
130+
131+
132+
def instrument_motor_motor_tornado(module):
133+
if hasattr(module, "MotorClient"):
134+
rollup = ("Datastore/all", "Datastore/MongoDB/all")
135+
# Name function explicitly as motor and pymongo have a history of overriding the
136+
# __getattr__() method in a way that breaks introspection.
137+
wrap_function_trace(
138+
module, "MotorClient.__init__", name=f"{module.__name__}:MotorClient.__init__", rollup=rollup
139+
)
40140

41-
patched_classes = ['MotorClient', 'MotorReplicaSetClient', 'MotorDatabase',
42-
'MotorCollection']
43-
for patched_class in patched_classes:
44-
if hasattr(module, patched_class):
45-
wrap_function_wrapper(module, f"{patched_class}.__getattr__",
46-
_nr_wrapper_Motor_getattr_)
141+
if hasattr(module, "MotorCollection"):
142+
for method_name in _motor_client_sync_methods:
143+
wrap_motor_method(module, "MotorCollection", method_name, is_async=False)
144+
for method_name in _motor_client_async_methods:
145+
wrap_motor_method(module, "MotorCollection", method_name, is_async=True)

tests/datastore_motor/conftest.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# Copyright 2010 New Relic, Inc.
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+
15+
import pytest
16+
from testing_support.db_settings import mongodb_settings
17+
from testing_support.fixture.event_loop import event_loop as loop # noqa
18+
from testing_support.fixtures import ( # noqa: F401; pylint: disable=W0611
19+
collector_agent_registration_fixture,
20+
collector_available_fixture,
21+
)
22+
23+
_default_settings = {
24+
"package_reporting.enabled": False, # Turn off package reporting for testing as it causes slow downs.
25+
"transaction_tracer.explain_threshold": 0.0,
26+
"transaction_tracer.transaction_threshold": 0.0,
27+
"transaction_tracer.stack_trace_threshold": 0.0,
28+
"debug.log_data_collector_payloads": True,
29+
"debug.record_transaction_failure": True,
30+
}
31+
32+
collector_agent_registration = collector_agent_registration_fixture(
33+
app_name="Python Agent Test (datastore_pymongo)",
34+
default_settings=_default_settings,
35+
linked_applications=["Python Agent Test (datastore)"],
36+
)
37+
38+
DB_SETTINGS = mongodb_settings()[0]
39+
MONGODB_HOST = DB_SETTINGS["host"]
40+
MONGODB_PORT = DB_SETTINGS["port"]
41+
MONGODB_COLLECTION = DB_SETTINGS["collection"]
42+
43+
44+
@pytest.fixture(scope="session", params=["asyncio", "tornado"])
45+
def implementation(request):
46+
return request.param
47+
48+
49+
@pytest.fixture(scope="session")
50+
def client(implementation):
51+
from motor.motor_asyncio import AsyncIOMotorClient
52+
from motor.motor_tornado import MotorClient as TornadoMotorClient
53+
54+
# Must be actually initialized in test function, so provide a callable that returns the client.
55+
def _client():
56+
if implementation == "asyncio":
57+
return AsyncIOMotorClient(host=MONGODB_HOST, port=MONGODB_PORT)
58+
else:
59+
return TornadoMotorClient(host=MONGODB_HOST, port=MONGODB_PORT)
60+
61+
return _client
62+
63+
64+
@pytest.fixture(scope="session")
65+
def init_metric(implementation):
66+
if implementation == "asyncio":
67+
return ("Function/motor.motor_asyncio:AsyncIOMotorClient.__init__", 1)
68+
else:
69+
return ("Function/motor.motor_tornado:MotorClient.__init__", 1)

0 commit comments

Comments
 (0)