Skip to content

Commit fd751af

Browse files
author
Andrew Slotin
authored
Pymongo instrumentation (instana#217)
* Add mongodb to the CircleCI build stack * Add basic pymongo command events listener implementation * Register MongoCommandTracer as a global event handler on startup * Preserve MongoDB command within tracer until execution is complete * Register mongo as an exit span * Initiate a new span each time a command is sent to MongoDB * Attach mongo command json to the span * Add MongoDBData span type * Send mongo spans and MongoDBData * Send mongo.json and mongo.filter span tags as JSON strings * Address mapreduce command case change in pymongo-3.9.0+ * Add myself to the contributors list and update the copyright year
1 parent 7499528 commit fd751af

File tree

8 files changed

+369
-6
lines changed

8 files changed

+369
-6
lines changed

.circleci/config.yml

+3
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jobs:
1616
- image: circleci/redis:5.0.4
1717
- image: rabbitmq:3.5.4
1818
- image: couchbase/server-sandbox:5.5.0
19+
- image: circleci/mongo:4.2.3-ram
1920

2021
working_directory: ~/repo
2122

@@ -70,6 +71,7 @@ jobs:
7071
- image: circleci/redis:5.0.4
7172
- image: rabbitmq:3.5.4
7273
- image: couchbase/server-sandbox:5.5.0
74+
- image: circleci/mongo:4.2.3-ram
7375

7476
working_directory: ~/repo
7577

@@ -122,6 +124,7 @@ jobs:
122124
- image: circleci/redis:5.0.4
123125
- image: rabbitmq:3.5.4
124126
- image: couchbase/server-sandbox:5.5.0
127+
- image: circleci/mongo:4.2.3-ram
125128

126129
working_directory: ~/repo
127130

instana/__init__.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@
2525
import pkg_resources
2626

2727
__author__ = 'Instana Inc.'
28-
__copyright__ = 'Copyright 2019 Instana Inc.'
29-
__credits__ = ['Pavlo Baron', 'Peter Giacomo Lombardo']
28+
__copyright__ = 'Copyright 2020 Instana Inc.'
29+
__credits__ = ['Pavlo Baron', 'Peter Giacomo Lombardo', 'Andrey Slotin']
3030
__license__ = 'MIT'
3131
__maintainer__ = 'Peter Giacomo Lombardo'
3232
__email__ = 'peter.lombardo@instana.com'
@@ -82,6 +82,7 @@ def boot_agent():
8282
from .instrumentation import sudsjurko
8383
from .instrumentation import urllib3
8484
from .instrumentation.django import middleware
85+
from .instrumentation import pymongo
8586

8687
# Hooks
8788
from .hooks import hook_uwsgi

instana/instrumentation/pymongo.py

+95
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
from __future__ import absolute_import
2+
3+
from ..log import logger
4+
from ..singletons import tracer
5+
6+
try:
7+
import pymongo
8+
from pymongo import monitoring
9+
from bson import json_util
10+
11+
class MongoCommandTracer(monitoring.CommandListener):
12+
def __init__(self):
13+
self.__active_commands = {}
14+
15+
def started(self, event):
16+
parent_span = tracer.active_span
17+
18+
# return early if we're not tracing
19+
if parent_span is None:
20+
return
21+
22+
with tracer.start_active_span("mongo", child_of=parent_span) as scope:
23+
self._collect_connection_tags(scope.span, event)
24+
self._collect_command_tags(scope.span, event)
25+
26+
# include collection name into the namespace if provided
27+
if event.command.has_key(event.command_name):
28+
scope.span.set_tag("collection", event.command.get(event.command_name))
29+
30+
self.__active_commands[event.request_id] = scope
31+
32+
def succeeded(self, event):
33+
active_span = self.__active_commands.pop(event.request_id, None)
34+
35+
# return early if we're not tracing
36+
if active_span is None:
37+
return
38+
39+
def failed(self, event):
40+
active_span = self.__active_commands.pop(event.request_id, None)
41+
42+
# return early if we're not tracing
43+
if active_span is None:
44+
return
45+
46+
active_span.log_exception(event.failure)
47+
48+
def _collect_connection_tags(self, span, event):
49+
(host, port) = event.connection_id
50+
51+
span.set_tag("driver", "pymongo")
52+
span.set_tag("host", host)
53+
span.set_tag("port", str(port))
54+
span.set_tag("db", event.database_name)
55+
56+
def _collect_command_tags(self, span, event):
57+
"""
58+
Extract MongoDB command name and arguments and attach it to the span
59+
"""
60+
cmd = event.command_name
61+
span.set_tag("command", cmd)
62+
63+
for key in ["filter", "query"]:
64+
if event.command.has_key(key):
65+
span.set_tag("filter", json_util.dumps(event.command.get(key)))
66+
break
67+
68+
# The location of command documents within the command object depends on the name
69+
# of this command. This is the name -> command object key mapping
70+
cmd_doc_locations = {
71+
"insert": "documents",
72+
"update": "updates",
73+
"delete": "deletes",
74+
"aggregate": "pipeline"
75+
}
76+
77+
cmd_doc = None
78+
if cmd in cmd_doc_locations:
79+
cmd_doc = event.command.get(cmd_doc_locations[cmd])
80+
elif cmd.lower() == "mapreduce": # mapreduce command was renamed to mapReduce in pymongo 3.9.0
81+
# mapreduce command consists of two mandatory parts: map and reduce
82+
cmd_doc = {
83+
"map": event.command.get("map"),
84+
"reduce": event.command.get("reduce")
85+
}
86+
87+
if cmd_doc is not None:
88+
span.set_tag("json", json_util.dumps(cmd_doc))
89+
90+
monitoring.register(MongoCommandTracer())
91+
92+
logger.debug("Instrumenting pymongo")
93+
94+
except ImportError:
95+
pass

instana/json_span.py

+9
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,15 @@ class MySQLData(BaseSpan):
9797
error = None
9898

9999

100+
class MongoDBData(BaseSpan):
101+
service = None
102+
namespace = None
103+
command = None
104+
filter = None
105+
json = None
106+
error = None
107+
108+
100109
class PostgresData(BaseSpan):
101110
db = None
102111
host = None

instana/recorder.py

+15-4
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import instana.singletons
1111

1212
from .json_span import (CassandraData, CouchbaseData, CustomData, Data, HttpData, JsonSpan, LogData,
13-
MySQLData, PostgresData, RabbitmqData, RedisData, RenderData,
13+
MongoDBData, MySQLData, PostgresData, RabbitmqData, RedisData, RenderData,
1414
RPCData, SDKData, SoapData, SQLAlchemyData)
1515

1616
from .log import logger
@@ -25,15 +25,16 @@
2525
class InstanaRecorder(SpanRecorder):
2626
THREAD_NAME = "Instana Span Reporting"
2727
registered_spans = ("aiohttp-client", "aiohttp-server", "cassandra", "couchbase", "django", "log",
28-
"memcache", "mysql", "postgres", "rabbitmq", "redis", "render", "rpc-client",
28+
"memcache", "mongo", "mysql", "postgres", "rabbitmq", "redis", "render", "rpc-client",
2929
"rpc-server", "sqlalchemy", "soap", "tornado-client", "tornado-server",
3030
"urllib3", "wsgi")
3131

3232
http_spans = ("aiohttp-client", "aiohttp-server", "django", "http", "soap", "tornado-client",
3333
"tornado-server", "urllib3", "wsgi")
3434

35-
exit_spans = ("aiohttp-client", "cassandra", "couchbase", "log", "memcache", "mysql", "postgres",
36-
"rabbitmq", "redis", "rpc-client", "sqlalchemy", "soap", "tornado-client", "urllib3")
35+
exit_spans = ("aiohttp-client", "cassandra", "couchbase", "log", "memcache", "mongo", "mysql", "postgres",
36+
"rabbitmq", "redis", "rpc-client", "sqlalchemy", "soap", "tornado-client", "urllib3",
37+
"pymongo")
3738

3839
entry_spans = ("aiohttp-server", "django", "wsgi", "rabbitmq", "rpc-server", "tornado-server")
3940

@@ -237,6 +238,16 @@ def build_registered_span(self, span):
237238
tskey = list(data.custom.logs.keys())[0]
238239
data.pg.error = data.custom.logs[tskey]['message']
239240

241+
elif span.operation_name == "mongo":
242+
service = "%s:%s" % (span.tags.pop('host', None), span.tags.pop('port', None))
243+
namespace = "%s.%s" % (span.tags.pop('db', "?"), span.tags.pop('collection', "?"))
244+
data.mongo = MongoDBData(service=service,
245+
namespace=namespace,
246+
command=span.tags.pop('command', None),
247+
filter=span.tags.pop('filter', None),
248+
json=span.tags.pop('json', None),
249+
error=span.tags.pop('command', None))
250+
240251
elif span.operation_name == "log":
241252
data.log = {}
242253
# use last special key values

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def check_setuptools():
8383
'pyOpenSSL>=16.1.0;python_version<="2.7"',
8484
'pytest>=3.0.1',
8585
'psycopg2>=2.7.1',
86+
'pymongo>=3.7.0',
8687
'redis>3.0.0',
8788
'requests>=2.17.1',
8889
'sqlalchemy>=1.1.15',

tests/helpers.py

+8
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@
4848
testenv['redis_host'] = os.environ.get('REDIS_HOST', '127.0.0.1')
4949

5050

51+
"""
52+
MongoDB Environment
53+
"""
54+
testenv['mongodb_host'] = os.environ.get('MONGO_HOST', '127.0.0.1')
55+
testenv['mongodb_port'] = os.environ.get('MONGO_PORT', '27017')
56+
testenv['mongodb_user'] = os.environ.get('MONGO_USER', None)
57+
testenv['mongodb_pw'] = os.environ.get('MONGO_PW', None)
58+
5159
def get_first_span_by_name(spans, name):
5260
for span in spans:
5361
if span.n == name:

0 commit comments

Comments
 (0)