Skip to content

Commit 49a641e

Browse files
author
Peter Giacomo Lombardo
authored
New boto3 instrumentation (#267)
* Add shell files * Base Boto3 instrumentation & tests * Add new test dependencies * Tests for s3, ses and sqs * Updated tags, safeties & tests * Remove unused mocks * More tags & tests * boto3 supported on Python > 3.5.3 only * Properly log and report errors * Dont run boto3 tests in unsupported versions * Updated http tags * Inject context when invoking lambdas * Avoid CLI options as service names - not cool * Partition boto3 dependencies * To Mock Lambda requires docker; skip
1 parent e527742 commit 49a641e

18 files changed

+866
-11
lines changed

instana/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ def boot_agent():
130130
from .instrumentation.aiohttp import client
131131
from .instrumentation.aiohttp import server
132132
from .instrumentation import asynqp
133+
from .instrumentation import boto3_inst
133134

134135
if sys.version_info[0] < 3:
135136
from .instrumentation import mysqlpython

instana/instrumentation/boto3_inst.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from __future__ import absolute_import
2+
3+
import json
4+
import wrapt
5+
import inspect
6+
7+
from ..log import logger
8+
from ..singletons import tracer
9+
10+
11+
try:
12+
import boto3
13+
from boto3.s3 import inject
14+
15+
def lambda_inject_context(payload, scope):
16+
"""
17+
When boto3 lambda client 'Invoke' is called, we want to inject the tracing context.
18+
boto3/botocore has specific requirements:
19+
https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/lambda.html#Lambda.Client.invoke
20+
"""
21+
try:
22+
invoke_payload = payload.get('Payload', {})
23+
24+
if not isinstance(invoke_payload, dict):
25+
invoke_payload = json.loads(invoke_payload)
26+
27+
tracer.inject(scope.span.context, 'http_headers', invoke_payload)
28+
payload['Payload'] = json.dumps(invoke_payload)
29+
except Exception:
30+
logger.debug("non-fatal lambda_inject_context: ", exc_info=True)
31+
32+
33+
@wrapt.patch_function_wrapper('botocore.client', 'BaseClient._make_api_call')
34+
def make_api_call_with_instana(wrapped, instance, arg_list, kwargs):
35+
# pylint: disable=protected-access
36+
parent_span = tracer.active_span
37+
38+
# If we're not tracing, just return
39+
if parent_span is None:
40+
return wrapped(*arg_list, **kwargs)
41+
42+
with tracer.start_active_span("boto3", child_of=parent_span) as scope:
43+
try:
44+
operation = arg_list[0]
45+
payload = arg_list[1]
46+
47+
scope.span.set_tag('op', operation)
48+
scope.span.set_tag('ep', instance._endpoint.host)
49+
scope.span.set_tag('reg', instance._client_config.region_name)
50+
51+
scope.span.set_tag('http.url', instance._endpoint.host + ':443/' + arg_list[0])
52+
scope.span.set_tag('http.method', 'POST')
53+
54+
# Don't collect payload for SecretsManager
55+
if not hasattr(instance, 'get_secret_value'):
56+
scope.span.set_tag('payload', payload)
57+
58+
# Inject context when invoking lambdas
59+
if 'lambda' in instance._endpoint.host and operation == 'Invoke':
60+
lambda_inject_context(payload, scope)
61+
62+
63+
except Exception as exc:
64+
logger.debug("make_api_call_with_instana: collect error", exc_info=True)
65+
66+
try:
67+
result = wrapped(*arg_list, **kwargs)
68+
69+
if isinstance(result, dict):
70+
http_dict = result.get('ResponseMetadata')
71+
if isinstance(http_dict, dict):
72+
status = http_dict.get('HTTPStatusCode')
73+
if status is not None:
74+
scope.span.set_tag('http.status_code', status)
75+
76+
return result
77+
except Exception as exc:
78+
scope.span.mark_as_errored({'error': exc})
79+
raise
80+
81+
def s3_inject_method_with_instana(wrapped, instance, arg_list, kwargs):
82+
fas = inspect.getfullargspec(wrapped)
83+
fas_args = fas.args
84+
fas_args.remove('self')
85+
86+
# pylint: disable=protected-access
87+
parent_span = tracer.active_span
88+
89+
# If we're not tracing, just return
90+
if parent_span is None:
91+
return wrapped(*arg_list, **kwargs)
92+
93+
with tracer.start_active_span("boto3", child_of=parent_span) as scope:
94+
try:
95+
operation = wrapped.__name__
96+
scope.span.set_tag('op', operation)
97+
scope.span.set_tag('ep', instance._endpoint.host)
98+
scope.span.set_tag('reg', instance._client_config.region_name)
99+
100+
scope.span.set_tag('http.url', instance._endpoint.host + ':443/' + operation)
101+
scope.span.set_tag('http.method', 'POST')
102+
103+
index = 1
104+
payload = {}
105+
arg_length = len(arg_list)
106+
107+
for arg_name in fas_args:
108+
payload[arg_name] = arg_list[index-1]
109+
110+
index += 1
111+
if index > arg_length:
112+
break
113+
114+
scope.span.set_tag('payload', payload)
115+
except Exception as exc:
116+
logger.debug("s3_inject_method_with_instana: collect error", exc_info=True)
117+
118+
try:
119+
return wrapped(*arg_list, **kwargs)
120+
except Exception as exc:
121+
scope.span.mark_as_errored({'error': exc})
122+
raise
123+
124+
for method in ['upload_file', 'upload_fileobj', 'download_file', 'download_fileobj']:
125+
wrapt.wrap_function_wrapper('boto3.s3.inject', method, s3_inject_method_with_instana)
126+
127+
logger.debug("Instrumenting boto3")
128+
except ImportError:
129+
pass

instana/recorder.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
# Accept, process and queue spans for eventual reporting.
12
from __future__ import absolute_import
23

34
import os
45
import sys
56

67
from basictracer import Sampler
78

8-
from .log import logger
99
from .span import (RegisteredSpan, SDKSpan)
1010

1111
if sys.version_info.major == 2:
@@ -17,7 +17,7 @@
1717
class StanRecorder(object):
1818
THREAD_NAME = "Instana Span Reporting"
1919

20-
REGISTERED_SPANS = ("aiohttp-client", "aiohttp-server", "aws.lambda.entry", "cassandra",
20+
REGISTERED_SPANS = ("aiohttp-client", "aiohttp-server", "aws.lambda.entry", "boto3", "cassandra",
2121
"celery-client", "celery-worker", "couchbase", "django", "gcs", "log",
2222
"memcache", "mongo", "mysql", "postgres", "pymongo", "rabbitmq", "redis",
2323
"render", "rpc-client", "rpc-server", "sqlalchemy", "soap", "tornado-client",

instana/span.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ class RegisteredSpan(BaseSpan):
230230
HTTP_SPANS = ("aiohttp-client", "aiohttp-server", "django", "http", "soap", "tornado-client",
231231
"tornado-server", "urllib3", "wsgi")
232232

233-
EXIT_SPANS = ("aiohttp-client", "cassandra", "celery-client", "couchbase", "log", "memcache",
233+
EXIT_SPANS = ("aiohttp-client", "boto3", "cassandra", "celery-client", "couchbase", "log", "memcache",
234234
"mongo", "mysql", "postgres", "rabbitmq", "redis", "rpc-client", "sqlalchemy",
235235
"soap", "tornado-client", "urllib3", "pymongo", "gcs")
236236

@@ -337,6 +337,18 @@ def _populate_exit_span_data(self, span):
337337
if span.operation_name in self.HTTP_SPANS:
338338
self._collect_http_tags(span)
339339

340+
elif span.operation_name == "boto3":
341+
# boto3 also sends http tags
342+
self._collect_http_tags(span)
343+
344+
for tag in ['op', 'ep', 'reg', 'payload', 'error']:
345+
value = span.tags.pop(tag, None)
346+
if value is not None:
347+
if tag == 'payload':
348+
self.data["boto3"][tag] = self._validate_tags(value)
349+
else:
350+
self.data["boto3"][tag] = value
351+
340352
elif span.operation_name == "cassandra":
341353
self.data["cassandra"]["cluster"] = span.tags.pop('cassandra.cluster', None)
342354
self.data["cassandra"]["query"] = span.tags.pop('cassandra.query', None)

instana/util.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -380,16 +380,29 @@ def determine_service_name():
380380
if "INSTANA_SERVICE_NAME" in os.environ:
381381
return os.environ["INSTANA_SERVICE_NAME"]
382382

383-
try:
384-
# Now best effort in naming this process. No nice package.json like in Node.js
385-
# so we do best effort detection here.
386-
app_name = "python" # the default name
383+
# Now best effort in naming this process. No nice package.json like in Node.js
384+
# so we do best effort detection here.
385+
app_name = "python" # the default name
386+
basename = None
387387

388+
try:
388389
if not hasattr(sys, 'argv'):
389390
proc_cmdline = get_proc_cmdline(as_string=False)
390391
return os.path.basename(proc_cmdline[0])
391392

392-
basename = os.path.basename(sys.argv[0])
393+
# Get first argument that is not an CLI option
394+
for candidate in sys.argv:
395+
if candidate[0] != '-':
396+
basename = candidate
397+
break
398+
399+
# If nothing found, fall back to executable
400+
if basename is None:
401+
basename = os.path.basename(sys.executable)
402+
else:
403+
# Assure leading paths are stripped
404+
basename = os.path.basename(basename)
405+
393406
if basename == "gunicorn":
394407
if 'setproctitle' in sys.modules:
395408
# With the setproctitle package, gunicorn renames their processes
@@ -435,9 +448,9 @@ def determine_service_name():
435448
app_name = uwsgi_type % app_name
436449
except ImportError:
437450
pass
438-
return app_name
439451
except Exception:
440452
logger.debug("get_application_name: ", exc_info=True)
453+
finally:
441454
return app_name
442455

443456

pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[pytest]
22
log_cli = 1
3-
log_cli_level = DEBUG
3+
log_cli_level = WARN
44
log_cli_format = %(asctime)s %(levelname)s %(message)s
55
log_cli_date_format = %H:%M:%S

setup.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,16 +93,18 @@ def check_setuptools():
9393
'test': [
9494
'aiohttp>=3.5.4;python_version>="3.5"',
9595
'asynqp>=0.4;python_version>="3.5"',
96+
'boto3>=1.10.0',
9697
'celery>=4.1.1',
9798
'django>=1.11,<2.2',
98-
'nose>=1.0',
9999
'flask>=0.12.2',
100100
'grpcio>=1.18.0',
101101
'google-cloud-storage>=1.24.0;python_version>="3.5"',
102102
'lxml>=3.4',
103103
'mock>=2.0.0',
104+
'moto>=1.3.16',
104105
'mysqlclient>=1.3.14;python_version>="3.5"',
105106
'MySQL-python>=1.2.5;python_version<="2.7"',
107+
'nose>=1.0',
106108
'PyMySQL[rsa]>=0.9.1',
107109
'pyOpenSSL>=16.1.0;python_version<="2.7"',
108110
'psycopg2>=2.7.1',

tests/apps/flask_app/app.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
#!/usr/bin/env python
22
# -*- coding: utf-8 -*-
3+
import os
34
import logging
45
import opentracing.ext.tags as ext
56
from flask import jsonify, Response
67
from wsgiref.simple_server import make_server
78
from flask import Flask, redirect, render_template, render_template_string
89

10+
try:
11+
import boto3
12+
from moto import mock_sqs
13+
except ImportError:
14+
# Doesn't matter. We won't call routes using boto3
15+
# in test sets that don't install/test for it.
16+
pass
17+
918
from ...helpers import testenv
1019
from instana.singletons import tracer
1120

@@ -153,6 +162,38 @@ def response_headers():
153162
return resp
154163

155164

165+
@app.route("/boto3/sqs")
166+
def boto3_sqs():
167+
os.environ['AWS_ACCESS_KEY_ID'] = 'testing'
168+
os.environ['AWS_SECRET_ACCESS_KEY'] = 'testing'
169+
os.environ['AWS_SECURITY_TOKEN'] = 'testing'
170+
os.environ['AWS_SESSION_TOKEN'] = 'testing'
171+
172+
with mock_sqs():
173+
boto3_client = boto3.client('sqs', region_name='us-east-1')
174+
response = boto3_client.create_queue(
175+
QueueName='SQS_QUEUE_NAME',
176+
Attributes={
177+
'DelaySeconds': '60',
178+
'MessageRetentionPeriod': '600'
179+
}
180+
)
181+
182+
queue_url = response['QueueUrl']
183+
response = boto3_client.send_message(
184+
QueueUrl=queue_url,
185+
DelaySeconds=10,
186+
MessageAttributes={
187+
'Website': {
188+
'DataType': 'String',
189+
'StringValue': 'https://www.instana.com'
190+
},
191+
},
192+
MessageBody=('Monitor any application, service, or request '
193+
'with Instana Application Performance Monitoring')
194+
)
195+
return Response(response)
196+
156197
@app.errorhandler(InvalidUsage)
157198
def handle_invalid_usage(error):
158199
logger.error("InvalidUsage error handler invoked")

tests/clients/boto3/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
If you would like to run this test server manually from an ipython console:
2+
3+
```
4+
import os
5+
import urllib3
6+
7+
from moto import mock_sqs
8+
import tests.apps.flask_app
9+
from tests.helpers import testenv
10+
from instana.singletons import tracer
11+
12+
http_client = urllib3.PoolManager()
13+
14+
os.environ['AWS_ACCESS_KEY_ID'] = 'testing'
15+
os.environ['AWS_SECRET_ACCESS_KEY'] = 'testing'
16+
os.environ['AWS_SECURITY_TOKEN'] = 'testing'
17+
os.environ['AWS_SESSION_TOKEN'] = 'testing'
18+
19+
@mock_sqs
20+
def test_app_boto3_sqs():
21+
with tracer.start_active_span('wsgi') as scope:
22+
scope.span.set_tag('span.kind', 'entry')
23+
scope.span.set_tag('http.host', 'localhost:80')
24+
scope.span.set_tag('http.path', '/')
25+
scope.span.set_tag('http.method', 'GET')
26+
scope.span.set_tag('http.status_code', 200)
27+
response = http_client.request('GET', testenv["wsgi_server"] + '/boto3/sqs')
28+
29+
```

tests/clients/boto3/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)