Repository: scoutapp/scout_apm_python
Pull request #437 (2020-01-02 11:38:59)

Test Starlette's synchronous endpoints
Starlette docs don't cover it much, but I just discovered it's possible to use synchronous functions for endpoints. Test that we work with them.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c7f761d..a175271 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@
   ([PR #430](https://github.com/scoutapp/scout_apm_python/pull/430)).
 - Add CherryPy integration
   ([PR #431](https://github.com/scoutapp/scout_apm_python/pull/431)).
+- Added tracking for missing PyMongo Collection methods
+  ([PR #436](https://github.com/scoutapp/scout_apm_python/pull/436)).
 
 ### Removed
 
diff --git a/src/scout_apm/instruments/pymongo.py b/src/scout_apm/instruments/pymongo.py
index e0063f1..0fbe9a8 100644
--- a/src/scout_apm/instruments/pymongo.py
+++ b/src/scout_apm/instruments/pymongo.py
@@ -25,7 +25,7 @@ def ensure_installed():
     if Collection is None:
         logger.info("Unable to import pymongo.Collection")
     elif not have_patched_collection:
-        for name in CLIENT_METHODS:
+        for name in COLLECTION_METHODS:
             try:
                 setattr(
                     Collection, name, wrap_collection_method(getattr(Collection, name))
@@ -40,10 +40,12 @@ def ensure_installed():
         have_patched_collection = True
 
 
-CLIENT_METHODS = [
+COLLECTION_METHODS = [
     "aggregate",
+    "aggregate_raw_batches",
     "bulk_write",
     "count",
+    "count_documents",
     "create_index",
     "create_indexes",
     "delete_many",
@@ -53,17 +55,23 @@ CLIENT_METHODS = [
     "drop_index",
     "drop_indexes",
     "ensure_index",
+    "estimated_document_count",
+    "find",
     "find_and_modify",
     "find_one",
     "find_one_and_delete",
     "find_one_and_replace",
     "find_one_and_update",
+    "find_raw_batches",
     "group",
+    "index_information",
     "inline_map_reduce",
     "insert",
     "insert_many",
     "insert_one",
+    "list_indexes",
     "map_reduce",
+    "parallel_scan",
     "reindex",
     "remove",
     "rename",


Test patch:
diff --git a/tests/integration/instruments/test_pymongo.py b/tests/integration/instruments/test_pymongo.py
index 2197fcd..2b44801 100644
--- a/tests/integration/instruments/test_pymongo.py
+++ b/tests/integration/instruments/test_pymongo.py
@@ -7,7 +7,7 @@ import os
 import pymongo
 import pytest
 
-from scout_apm.instruments.pymongo import ensure_installed
+from scout_apm.instruments.pymongo import COLLECTION_METHODS, ensure_installed
 from tests.compat import mock
 
 
@@ -20,6 +20,39 @@ def pymongo_client():
     yield pymongo.MongoClient(os.environ["MONGODB_URL"])
 
 
+def test_all_collection_attributes_accounted_for():
+    all_methods = {
+        m for m in dir(pymongo.collection.Collection) if not m.startswith("_")
+    }
+    deliberately_ignored_methods = {
+        # Properties:
+        "codec_options",
+        "database",
+        "full_name",
+        "name",
+        "read_concern",
+        "read_preference",
+        "write_concern",
+        # Non-querying methods:
+        "initialize_ordered_bulk_op",
+        "initialize_unordered_bulk_op",
+        "next",
+        "options",
+        "with_options",
+        # Returns a long running iterator rather than doing a direct query;
+        # probably won't be used in web requests:
+        "watch",
+    }
+    assert (
+        all_methods - deliberately_ignored_methods - set(COLLECTION_METHODS)
+    ) == set()
+
+
+@pytest.mark.parametrize(["method_name"], [[x] for x in COLLECTION_METHODS])
+def test_all_collection_methods_exist(method_name):
+    assert hasattr(pymongo.collection.Collection, method_name)
+
+
 def test_ensure_installed_twice(caplog):
     ensure_installed()
     ensure_installed()
diff --git a/tests/integration/test_starlette_py36plus.py b/tests/integration/test_starlette_py36plus.py
index 7a88f1f..d2a98bf 100644
--- a/tests/integration/test_starlette_py36plus.py
+++ b/tests/integration/test_starlette_py36plus.py
@@ -42,10 +42,17 @@ def app_with_scout(*, middleware=None, scout_config=None):
     async def home(request):
         return PlainTextResponse("Welcome home.")
 
+    def sync_home(request):
+        return PlainTextResponse("Welcome home, synchronously.")
+
     class HelloEndpoint(HTTPEndpoint):
         async def get(self, request):
             return PlainTextResponse("Hello World!")
 
+    class SyncHelloEndpoint(HTTPEndpoint):
+        def get(self, request):
+            return PlainTextResponse("Hello Synchronous World!")
+
     async def crash(request):
         raise ValueError("BØØM!")  # non-ASCII
 
@@ -67,7 +74,9 @@ def app_with_scout(*, middleware=None, scout_config=None):
 
     routes = [
         Route("/", endpoint=home),
+        Route("/sync-home/", endpoint=sync_home),
         Route("/hello/", endpoint=HelloEndpoint),
+        Route("/sync-hello/", endpoint=SyncHelloEndpoint),
         Route("/crash/", endpoint=crash),
         Route("/return-error/", endpoint=return_error),
         Route("/background-jobs/", endpoint=background_jobs),
@@ -120,6 +129,30 @@ async def test_home(tracked_requests):
     )
 
 
+@async_test
+async def test_sync_home(tracked_requests):
+    with app_with_scout() as app:
+        communicator = ApplicationCommunicator(app, asgi_http_scope(path="/sync-home/"))
+        await communicator.send_input({"type": "http.request"})
+        # Read the response.
+        response_start = await communicator.receive_output()
+        response_body = await communicator.receive_output()
+
+    assert response_start["type"] == "http.response.start"
+    assert response_start["status"] == 200
+    assert response_body["type"] == "http.response.body"
+    assert response_body["body"] == b"Welcome home, synchronously."
+    assert len(tracked_requests) == 1
+    tracked_request = tracked_requests[0]
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.tags["path"] == "/sync-home/"
+    span = tracked_request.complete_spans[0]
+    assert span.operation == (
+        "Controller/tests.integration.test_starlette_py36plus."
+        + "app_with_scout.<locals>.sync_home"
+    )
+
+
 @async_test
 async def test_home_ignored(tracked_requests):
     with app_with_scout(scout_config={"ignore": ["/"]}) as app:
@@ -160,6 +193,32 @@ async def test_hello(tracked_requests):
     )
 
 
+@async_test
+async def test_sync_hello(tracked_requests):
+    with app_with_scout() as app:
+        communicator = ApplicationCommunicator(
+            app, asgi_http_scope(path="/sync-hello/")
+        )
+        await communicator.send_input({"type": "http.request"})
+        # Read the response.
+        response_start = await communicator.receive_output()
+        response_body = await communicator.receive_output()
+
+    assert response_start["type"] == "http.response.start"
+    assert response_start["status"] == 200
+    assert response_body["type"] == "http.response.body"
+    assert response_body["body"] == b"Hello Synchronous World!"
+    assert len(tracked_requests) == 1
+    tracked_request = tracked_requests[0]
+    assert len(tracked_request.complete_spans) == 1
+    assert tracked_request.tags["path"] == "/sync-hello/"
+    span = tracked_request.complete_spans[0]
+    assert span.operation == (
+        "Controller/tests.integration.test_starlette_py36plus."
+        + "app_with_scout.<locals>.SyncHelloEndpoint"
+    )
+
+
 @async_test
 async def test_not_found(tracked_requests):
     with app_with_scout() as app: