diff --git a/README.md b/README.md index 397ad843..55bad83b 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ Library | Plugin Name | [urllib.request](https://docs.python.org/3/library/urllib.request.html) | `sw_urllib_request` | | [requests](https://requests.readthedocs.io/en/master/) | `sw_requests` | | [Flask](https://flask.palletsprojects.com/en/1.1.x/) | `sw_flask` | +| [PyMySQL](https://pymysql.readthedocs.io/en/latest/) | `sw_pymysql` | ## API diff --git a/setup.py b/setup.py index 39c495f7..c83df15f 100644 --- a/setup.py +++ b/setup.py @@ -43,7 +43,8 @@ extras_require={ "test": [ "testcontainers", - "Werkzeug" + "Werkzeug", + "pymysql", ], }, classifiers=[ diff --git a/skywalking/__init__.py b/skywalking/__init__.py index f826ad6a..ae9f4c45 100644 --- a/skywalking/__init__.py +++ b/skywalking/__init__.py @@ -26,6 +26,7 @@ class Component(Enum): General = 7000 # built-in modules that may not have a logo to display Flask = 7001 Requests = 7002 + PyMysql = 7003 class Layer(Enum): diff --git a/skywalking/plugins/sw_pymysql/__init__.py b/skywalking/plugins/sw_pymysql/__init__.py new file mode 100644 index 00000000..cc512f5b --- /dev/null +++ b/skywalking/plugins/sw_pymysql/__init__.py @@ -0,0 +1,57 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +import logging + +from skywalking import Layer, Component +from skywalking.trace import tags +from skywalking.trace.carrier import Carrier +from skywalking.trace.context import get_context +from skywalking.trace.tags import Tag + +logger = logging.getLogger(__name__) + + +def install(): + # noinspection PyBroadException + try: + from pymysql.cursors import Cursor + + _execute = Cursor.execute + + def _sw_execute(this: Cursor, query, args=None): + peer = "%s:%s" % (this.connection.host, this.connection.port) + + context = get_context() + carrier = Carrier() + with context.new_exit_span(op="Mysql/PyMsql/execute", peer=peer, carrier=carrier) as span: + span.layer = Layer.Database + span.component = Component.PyMysql + try: + res = _execute(this, query, args) + + span.tag(Tag(key=tags.DbType, val="mysql")) + span.tag(Tag(key=tags.DbInstance, val=this.connection.db.decode("utf-8"))) + span.tag(Tag(key=tags.DbStatement, val=query)) + + except BaseException as e: + span.raised() + raise e + return res + + Cursor.execute = _sw_execute + except Exception: + logger.warning('failed to install plugin %s', __name__) diff --git a/skywalking/trace/tags/__init__.py b/skywalking/trace/tags/__init__.py index 483e71d8..0c2ec18f 100644 --- a/skywalking/trace/tags/__init__.py +++ b/skywalking/trace/tags/__init__.py @@ -23,3 +23,6 @@ HttpUrl = 'url' HttpMethod = 'http.method' HttpStatus = 'status.code' +DbType = 'db.type' +DbInstance = 'db.instance' +DbStatement = 'db.statement' diff --git a/tests/plugin/sw_pymysql/__init__.py b/tests/plugin/sw_pymysql/__init__.py new file mode 100644 index 00000000..b1312a09 --- /dev/null +++ b/tests/plugin/sw_pymysql/__init__.py @@ -0,0 +1,16 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/tests/plugin/sw_pymysql/docker-compose.yml b/tests/plugin/sw_pymysql/docker-compose.yml new file mode 100644 index 00000000..9685624f --- /dev/null +++ b/tests/plugin/sw_pymysql/docker-compose.yml @@ -0,0 +1,78 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '2.1' + +services: + collector: + extends: + service: collector + file: ../docker/docker-compose.base.yml + + mysql: + image: mysql:5.7 + hostname: mysql + ports: + - 3306:3306 + - 33060:33060 + environment: + - MYSQL_ROOT_PASSWORD=root + - MYSQL_DATABASE=test + healthcheck: + test: ["CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/3306"] + interval: 5s + timeout: 60s + retries: 120 + networks: + - beyond + + provider: + extends: + service: agent + file: ../docker/docker-compose.base.yml + ports: + - 9091:9091 + volumes: + - ./services/provider.py:/app/provider.py + command: ['bash', '-c', 'pip install flask && pip install PyMySQL && python3 /app/provider.py'] + depends_on: + collector: + condition: service_healthy + + healthcheck: + test: ["CMD", "bash", "-c", "cat < /dev/null > /dev/tcp/127.0.0.1/9091"] + interval: 5s + timeout: 60s + retries: 120 + + consumer: + extends: + service: agent + file: ../docker/docker-compose.base.yml + ports: + - 9090:9090 + volumes: + - ./services/consumer.py:/app/consumer.py + command: ['bash', '-c', 'pip install flask && python3 /app/consumer.py'] + depends_on: + collector: + condition: service_healthy + provider: + condition: service_healthy + +networks: + beyond: diff --git a/tests/plugin/sw_pymysql/expected.data.yml b/tests/plugin/sw_pymysql/expected.data.yml new file mode 100644 index 00000000..127b893d --- /dev/null +++ b/tests/plugin/sw_pymysql/expected.data.yml @@ -0,0 +1,109 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +segmentItems: + - serviceName: provider + segmentSize: 1 + segments: + - segmentId: not null + spans: + - operationName: Mysql/PyMsql/execute + operationId: 0 + parentSpanId: 0 + spanId: 1 + spanLayer: Database + tags: + - key: db.type + value: mysql + - key: db.instance + value: test + - key: db.statement + value: select 1 + startTime: gt 0 + endTime: gt 0 + componentId: 7003 + spanType: Exit + peer: mysql:3306 + skipAnalysis: false + - operationName: /users + operationId: 0 + parentSpanId: -1 + spanId: 0 + spanLayer: Http + tags: + - key: http.method + value: POST + - key: url + value: http://provider:9091/users + - key: status.code + value: '200' + refs: + - parentEndpoint: /users + networkAddress: provider:9091 + refType: CrossProcess + parentSpanId: 1 + parentTraceSegmentId: not null + parentServiceInstance: not null + parentService: consumer + traceId: not null + startTime: gt 0 + endTime: gt 0 + componentId: 7001 + spanType: Entry + peer: not null + skipAnalysis: false + - serviceName: consumer + segmentSize: 1 + segments: + - segmentId: not null + spans: + - operationName: /users + operationId: 0 + parentSpanId: 0 + spanId: 1 + spanLayer: Http + tags: + - key: http.method + value: POST + - key: url + value: http://provider:9091/users + - key: status.code + value: '200' + startTime: gt 0 + endTime: gt 0 + componentId: 7002 + spanType: Exit + peer: provider:9091 + skipAnalysis: false + - operationName: /users + operationId: 0 + parentSpanId: -1 + spanId: 0 + spanLayer: Http + tags: + - key: http.method + value: GET + - key: url + value: http://0.0.0.0:9090/users + - key: status.code + value: '200' + startTime: gt 0 + endTime: gt 0 + componentId: 7001 + spanType: Entry + peer: not null + skipAnalysis: false \ No newline at end of file diff --git a/tests/plugin/sw_pymysql/services/__init__.py b/tests/plugin/sw_pymysql/services/__init__.py new file mode 100644 index 00000000..b1312a09 --- /dev/null +++ b/tests/plugin/sw_pymysql/services/__init__.py @@ -0,0 +1,16 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/tests/plugin/sw_pymysql/services/consumer.py b/tests/plugin/sw_pymysql/services/consumer.py new file mode 100644 index 00000000..c942f91c --- /dev/null +++ b/tests/plugin/sw_pymysql/services/consumer.py @@ -0,0 +1,37 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import requests + +from skywalking import agent, config + +if __name__ == '__main__': + config.service_name = 'consumer' + config.logging_level = 'DEBUG' + agent.start() + + from flask import Flask, jsonify + + app = Flask(__name__) + + @app.route("/users", methods=["POST", "GET"]) + def application(): + res = requests.post("http://provider:9091/users") + return jsonify(res.json()) + + PORT = 9090 + app.run(host='0.0.0.0', port=PORT, debug=True) diff --git a/tests/plugin/sw_pymysql/services/provider.py b/tests/plugin/sw_pymysql/services/provider.py new file mode 100644 index 00000000..a0a305a0 --- /dev/null +++ b/tests/plugin/sw_pymysql/services/provider.py @@ -0,0 +1,45 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import time + +from skywalking import agent, config + +if __name__ == '__main__': + config.service_name = 'provider' + config.logging_level = 'DEBUG' + agent.start() + + from flask import Flask, jsonify + import pymysql.cursors + + app = Flask(__name__) + + @app.route("/users", methods=["POST", "GET"]) + def application(): + time.sleep(0.5) + connection = pymysql.connect(host='mysql', user='root', password='root', db='test', charset='utf8mb4') + with connection.cursor() as cursor: + sql = "select 1" + cursor.execute(sql) + + connection.close() + + return jsonify({"song": "Despacito", "artist": "Luis Fonsi"}) + + PORT = 9091 + app.run(host='0.0.0.0', port=PORT, debug=True) diff --git a/tests/plugin/sw_pymysql/test_pymysql.py b/tests/plugin/sw_pymysql/test_pymysql.py new file mode 100644 index 00000000..dfeaaa6c --- /dev/null +++ b/tests/plugin/sw_pymysql/test_pymysql.py @@ -0,0 +1,43 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import os +import time +import unittest +from os.path import abspath, dirname + +from testcontainers.compose import DockerCompose + +from tests.plugin import BasePluginTest + + +class TestPlugin(BasePluginTest): + @classmethod + def setUpClass(cls): + cls.compose = DockerCompose(filepath=dirname(abspath(__file__))) + cls.compose.start() + + cls.compose.wait_for(cls.url(('consumer', '9090'), 'users')) + + def test_request_plugin(self): + time.sleep(3) + + self.validate(expected_file_name=os.path.join(dirname(abspath(__file__)), 'expected.data.yml')) + + +if __name__ == '__main__': + unittest.main()