diff --git a/README.md b/README.md index 608a843b..4fa33d71 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,19 @@ xray_recorder.configure(service='fallback_name', dynamic_naming='*mysite.com*') XRayMiddleware(app, xray_recorder) ``` -### Serverless Support for Flask & Django Using X-Ray +### Add Bottle middleware(plugin) + +```python +from aws_xray_sdk.core import xray_recorder +from aws_xray_sdk.ext.bottle.middleware import XRayMiddleware + +app = Bottle() + +xray_recorder.configure(service='fallback_name', dynamic_naming='*mysite.com*') +app.install(XRayMiddleware(xray_recorder)) +``` + +### Serverless Support for Flask & Django & Bottle Using X-Ray Serverless is an application model that enables you to shift more of your operational responsibilities to AWS. As a result, you can focus only on your applications and services, instead of the infrastructure management tasks such as server provisioning, patching, operating system maintenance, and capacity provisioning. With serverless, you can deploy your web application to [AWS Lambda](https://aws.amazon.com/lambda/) and have customers interact with it through a Lambda-invoking endpoint, such as [Amazon API Gateway](https://aws.amazon.com/api-gateway/). X-Ray supports the Serverless model out of the box and requires no extra configuration. The middlewares in Lambda generate `Subsegments` instead of `Segments` when an endpoint is reached. This is because `Segments` cannot be generated inside the Lambda function, but it is generated automatically by the Lambda container. Therefore, when using the middlewares with this model, it is important to make sure that your methods only generate `Subsegments`. diff --git a/aws_xray_sdk/ext/bottle/__init__.py b/aws_xray_sdk/ext/bottle/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/aws_xray_sdk/ext/bottle/middleware.py b/aws_xray_sdk/ext/bottle/middleware.py new file mode 100644 index 00000000..f51d9ca0 --- /dev/null +++ b/aws_xray_sdk/ext/bottle/middleware.py @@ -0,0 +1,111 @@ +from bottle import request, response, SimpleTemplate + +from aws_xray_sdk.core.lambda_launcher import check_in_lambda, LambdaContext +from aws_xray_sdk.core.models import http +from aws_xray_sdk.core.utils import stacktrace +from aws_xray_sdk.ext.util import calculate_sampling_decision, \ + calculate_segment_name, construct_xray_header, prepare_response_header + + +class XRayMiddleware(object): + """ + Middleware that wraps each incoming request to a segment. + """ + name = 'xray' + api = 2 + + def __init__(self, recorder): + self._recorder = recorder + self._in_lambda_ctx = False + + if check_in_lambda() and type(self._recorder.context) == LambdaContext: + self._in_lambda_ctx = True + + _patch_render(recorder) + + def apply(self, callback, route): + """ + Apply middleware directly to each route callback. + """ + def wrapper(*a, **ka): + headers = request.headers + xray_header = construct_xray_header(headers) + name = calculate_segment_name(request.urlparts[1], self._recorder) + + sampling_req = { + 'host': request.urlparts[1], + 'method': request.method, + 'path': request.path, + 'service': name, + } + sampling_decision = calculate_sampling_decision( + trace_header=xray_header, + recorder=self._recorder, + sampling_req=sampling_req, + ) + + if self._in_lambda_ctx: + segment = self._recorder.begin_subsegment(name) + else: + segment = self._recorder.begin_segment( + name=name, + traceid=xray_header.root, + parent_id=xray_header.parent, + sampling=sampling_decision, + ) + + segment.save_origin_trace_header(xray_header) + segment.put_http_meta(http.URL, request.url) + segment.put_http_meta(http.METHOD, request.method) + segment.put_http_meta(http.USER_AGENT, headers.get('User-Agent')) + + client_ip = request.environ.get('HTTP_X_FORWARDED_FOR') or request.environ.get('REMOTE_ADDR') + if client_ip: + segment.put_http_meta(http.CLIENT_IP, client_ip) + segment.put_http_meta(http.X_FORWARDED_FOR, True) + else: + segment.put_http_meta(http.CLIENT_IP, request.remote_addr) + + try: + rv = callback(*a, **ka) + except Exception as resp: + segment.put_http_meta(http.STATUS, getattr(resp, 'status_code', 500)) + stack = stacktrace.get_stacktrace(limit=self._recorder._max_trace_back) + segment.add_exception(resp, stack) + if self._in_lambda_ctx: + self._recorder.end_subsegment() + else: + self._recorder.end_segment() + + raise resp + + segment.put_http_meta(http.STATUS, response.status_code) + + origin_header = segment.get_origin_trace_header() + resp_header_str = prepare_response_header(origin_header, segment) + response.set_header(http.XRAY_HEADER, resp_header_str) + + cont_len = response.headers.get('Content-Length') + if cont_len: + segment.put_http_meta(http.CONTENT_LENGTH, int(cont_len)) + + if self._in_lambda_ctx: + self._recorder.end_subsegment() + else: + self._recorder.end_segment() + + return rv + + return wrapper + +def _patch_render(recorder): + + _render = SimpleTemplate.render + + @recorder.capture('template_render') + def _traced_render(self, *args, **kwargs): + if self.filename: + recorder.current_subsegment().name = self.filename + return _render(self, *args, **kwargs) + + SimpleTemplate.render = _traced_render diff --git a/tests/ext/bottle/__init__.py b/tests/ext/bottle/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/ext/bottle/test_bottle.py b/tests/ext/bottle/test_bottle.py new file mode 100644 index 00000000..81e57970 --- /dev/null +++ b/tests/ext/bottle/test_bottle.py @@ -0,0 +1,304 @@ +import pytest +from bottle import Bottle, request, response, template, view, HTTPError, TEMPLATE_PATH +from webtest import TestApp as WebApp + +from aws_xray_sdk import global_sdk_config +from aws_xray_sdk.ext.bottle.middleware import XRayMiddleware +from aws_xray_sdk.core.context import Context +from aws_xray_sdk.core import lambda_launcher +from aws_xray_sdk.core.models import http, facade_segment, segment as segment_model +from tests.util import get_new_stubbed_recorder +import os + + +# define Bottle app for testing purpose +TEMPLATE_PATH.insert(0, os.path.dirname(__file__) + '/views') +app = Bottle() + + +@app.route('/ok') +def ok(): + response_data = 'ok' + # Bottle not always set Content-Length header + response.content_length = len(response_data) + return response_data + + +@app.route('/error') +def error(): + response.status = 404 + return 'Not Found' + + +@app.route('/client_error') +def faulty_client(): + class CustomError(Exception): + def __init__(self, description=None, status_code=None): + self.description = description + self.status_code = status_code + + raise CustomError(description='Bad request', status_code=400) + + +@app.route('/server_error') +def faulty_server(): + raise HTTPError(status=503, body='Service Unavailable') + + +@app.route('/fault') +def fault(): + return {}['key'] + + +@app.route('/template') +def template_(): + return template('Hello {{name}}!', name='World') + + +@app.route('/view') +@view('index') +def view_(name='bottle'): + return dict(name=name) + + +# add X-Ray plugin to Bottle app +recorder = get_new_stubbed_recorder() +recorder.configure(service='test', sampling=False, context=Context()) +app.install(XRayMiddleware(recorder)) + +app = WebApp(app) + +BASE_URL = 'http://localhost:80{}' + + +@pytest.fixture(autouse=True) +def cleanup(): + """ + Clean up context storage before and after each test run + """ + recorder.clear_trace_entities() + yield + recorder.clear_trace_entities() + global_sdk_config.set_sdk_enabled(True) + + +def test_ok(): + path = '/ok' + app.get(path, extra_environ={'REMOTE_ADDR': '127.0.0.1'}) + segment = recorder.emitter.pop() + assert not segment.in_progress + + request = segment.http['request'] + response = segment.http['response'] + + assert request['method'] == 'GET' + assert request['url'] == BASE_URL.format(path) + assert request['client_ip'] == '127.0.0.1' + assert response['status'] == 200 + assert response['content_length'] == 2 + + +def test_error(): + path = '/error' + try: + app.get(path, extra_environ={'HTTP_X_FORWARDED_FOR': '192.168.0.0'}) + except Exception: + pass + segment = recorder.emitter.pop() + assert not segment.in_progress + assert segment.error + + request = segment.http['request'] + response = segment.http['response'] + assert request['method'] == 'GET' + assert request['url'] == BASE_URL.format(path) + assert request['client_ip'] == '192.168.0.0' + assert response['status'] == 404 + + +def test_custom_client_error(): + path = '/client_error' + try: + app.get(path) + except Exception: + pass + segment = recorder.emitter.pop() + assert not segment.in_progress + assert segment.error + + response = segment.http['response'] + assert response['status'] == 400 + exception = segment.cause['exceptions'][0] + assert exception.type == 'CustomError' + + request = segment.http['request'] + assert request['method'] == 'GET' + assert request['url'] == BASE_URL.format(path) + + +def test_server_error(): + path = '/server_error' + try: + app.get(path) + except Exception as e: + pass + segment = recorder.emitter.pop() + assert not segment.in_progress + assert segment.fault + + response = segment.http['response'] + assert response['status'] == 503 + + exception = segment.cause['exceptions'][0] + assert exception.type == 'HTTPError' + + +def test_fault(): + path = '/fault' + try: + app.get(path) + except Exception: + pass + segment = recorder.emitter.pop() + assert not segment.in_progress + assert segment.fault + + response = segment.http['response'] + assert response['status'] == 500 + + exception = segment.cause['exceptions'][0] + assert exception.type == 'KeyError' + + +def test_render_template(): + path = '/template' + app.get(path) + segment = recorder.emitter.pop() + assert not segment.in_progress + # segment should contain a template render subsegment + assert segment.subsegments + + subsegment = segment.subsegments[0] + assert subsegment.name + assert subsegment.namespace == 'local' + assert not subsegment.in_progress + + +def test_render_view(): + path = '/view' + response = app.get(path) + assert response.text == "

Hello Bottle!

\n

How are you?

\n" + segment = recorder.emitter.pop() + assert not segment.in_progress + # segment should contain a template render subsegment + assert segment.subsegments + + subsegment = segment.subsegments[0] + assert subsegment.name + assert subsegment.namespace == 'local' + assert not subsegment.in_progress + + +def test_incoming_sampling_decision_respected(): + path = '/ok' + # resp = app.get(path, headers={http.XRAY_HEADER: 'Sampled=0'}) + resp = app.get(path, headers={http.XRAY_HEADER: 'Sampled=0'}) + resp_header = resp.headers[http.XRAY_HEADER] + segment = recorder.emitter.pop() + + assert not segment + # The SDK should still send the headers back regardless of sampling decision + assert 'Root' in resp_header + + +def test_trace_header_data_perservation(): + path = '/ok' + app.get(path, headers={http.XRAY_HEADER: 'k1=v1'}) + segment = recorder.emitter.pop() + header = segment.get_origin_trace_header() + + assert header.data['k1'] == 'v1' + + +def test_sampled_response_header(): + path = '/ok' + app.get(path, headers={http.XRAY_HEADER: 'Sampled=?;k1=v1'}) + segment = recorder.emitter.pop() + + resp_header = response.headers.get(http.XRAY_HEADER) + assert segment.trace_id in resp_header + assert 'Sampled=1' in resp_header + + +def test_disabled_sdk(): + global_sdk_config.set_sdk_enabled(False) + path = '/ok' + app.get(path) + segment = recorder.emitter.pop() + assert not segment + + +def test_lambda_serverless(): + TRACE_ID = '1-5759e988-bd862e3fe1be46a994272793' + PARENT_ID = '53995c3f42cd8ad8' + HEADER_VAR = 'Root=%s;Parent=%s;Sampled=1' % (TRACE_ID, PARENT_ID) + + os.environ[lambda_launcher.LAMBDA_TRACE_HEADER_KEY] = HEADER_VAR + lambda_context = lambda_launcher.LambdaContext() + + new_recorder = get_new_stubbed_recorder() + new_recorder.configure(service='test', sampling=False, context=lambda_context) + new_app = Bottle() + + @new_app.route('/subsegment') + def subsegment_(): + # Test in between request and make sure Serverless creates a subsegment instead of a segment. + # Ensure that the parent segment is a facade segment. + assert new_recorder.current_subsegment() + assert type(new_recorder.current_segment()) == facade_segment.FacadeSegment + return 'ok' + + @new_app.route('/trace_header') + def trace_header(): + # Ensure trace header is preserved. + subsegment = new_recorder.current_subsegment() + header = subsegment.get_origin_trace_header() + assert header.data['k1'] == 'v1' + return 'ok' + + plugin = XRayMiddleware(new_recorder) + plugin._in_lambda_ctx = True + new_app.install(plugin) + + app_client = WebApp(new_app) + + path = '/subsegment' + app_client.get(path) + new_app.get(path) + segment = recorder.emitter.pop() + assert not segment # Segment should be none because it's created and ended by the plugin + + path2 = '/trace_header' + app_client.get(path2, headers={http.XRAY_HEADER: 'k1=v1'}) + + +def test_lambda_default_ctx(): + # Track to make sure that Bottle will default to generating segments if context is not the lambda context + new_recorder = get_new_stubbed_recorder() + new_recorder.configure(service='test', sampling=False) + new_app = Bottle() + + @new_app.route('/segment') + def segment_(): + # Test in between request and make sure Lambda that uses default context generates a segment. + assert new_recorder.current_segment() + assert type(new_recorder.current_segment()) == segment_model.Segment + return 'ok' + + new_app.install(XRayMiddleware(new_recorder)) + app_client = WebApp(new_app) + + path = '/segment' + app_client.get(path) + segment = recorder.emitter.pop() + assert not segment # Segment should be none because it's created and ended by the plugin diff --git a/tests/ext/bottle/views/index.tpl b/tests/ext/bottle/views/index.tpl new file mode 100644 index 00000000..a5a5c278 --- /dev/null +++ b/tests/ext/bottle/views/index.tpl @@ -0,0 +1,2 @@ +

Hello {{name.title()}}!

+

How are you?

diff --git a/tox.ini b/tox.ini index 39a5c678..d829182d 100644 --- a/tox.ini +++ b/tox.ini @@ -13,6 +13,7 @@ deps = coverage==4.5.4 codecov requests + bottle >= 0.10 flask >= 0.10 sqlalchemy Flask-SQLAlchemy @@ -24,6 +25,7 @@ deps = psycopg2 pg8000 testing.postgresql + webtest # Python2 only deps py{27}: enum34