├── .bandit.yml ├── .coveragerc ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── dependabot-auto-merge.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .pyup.yml ├── CHANGES.rst ├── CHANGES └── .TEMPLATE.rst ├── CONTRIBUTING.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── aiozipkin ├── __init__.py ├── aiohttp_helpers.py ├── constants.py ├── context_managers.py ├── helpers.py ├── log.py ├── mypy_types.py ├── py.typed ├── record.py ├── sampler.py ├── span.py ├── tracer.py ├── transport.py └── utils.py ├── docs ├── Makefile ├── _static │ └── aiohttp-icon-128x128.png ├── api.rst ├── conf.py ├── contributing.rst ├── examples.rst ├── index.rst ├── jaeger.png ├── make.bat ├── other.rst ├── spelling_wordlist.txt ├── stackdriver.png ├── tutorial.rst ├── zipkin_animation2.gif ├── zipkin_glossary.png └── zipkin_ui.png ├── examples ├── aiohttp_example.py ├── microservices │ ├── README.rst │ ├── runner.py │ ├── service_a.py │ ├── service_b.py │ ├── service_c.py │ ├── service_d.py │ ├── service_e.py │ └── templates │ │ └── index.html ├── minimal.py ├── queue │ ├── backend.py │ ├── frontend.py │ └── runner.py └── simple.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements-doc.txt ├── setup.cfg ├── setup.py └── tests ├── conftest.py ├── docker_fixtures.py ├── test_aiohttp_helpers.py ├── test_aiohttp_integration.py ├── test_helpers.py ├── test_jaeger.py ├── test_record.py ├── test_sampler.py ├── test_tracer.py ├── test_transport.py ├── test_utils.py └── test_zipkin.py /.bandit.yml: -------------------------------------------------------------------------------- 1 | skips: ['B101'] 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = aiozipkin 4 | omit = site-packages, .tox 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | ignore: 9 | - dependency-name: pre-commit 10 | versions: 11 | - 2.10.0 12 | - 2.10.1 13 | - 2.11.0 14 | - 2.11.1 15 | - 2.12.0 16 | - dependency-name: sphinx 17 | versions: 18 | - 3.4.3 19 | - 3.5.0 20 | - 3.5.1 21 | - 3.5.2 22 | - 3.5.3 23 | - dependency-name: twine 24 | versions: 25 | - 3.4.0 26 | - dependency-name: flake8 27 | versions: 28 | - 3.9.0 29 | - dependency-name: flake8-bugbear 30 | versions: 31 | - 21.3.1 32 | - 21.3.2 33 | - dependency-name: pyroma 34 | versions: 35 | - 2.6.1 36 | - 3.0.1 37 | - dependency-name: pytest 38 | versions: 39 | - 6.2.2 40 | - dependency-name: mypy 41 | versions: 42 | - "0.800" 43 | 44 | - package-ecosystem: "github-actions" 45 | directory: "/" 46 | labels: 47 | - dependencies 48 | schedule: 49 | interval: "daily" 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | tags: [ 'v*' ] 7 | pull_request: 8 | branches: [ master ] 9 | schedule: 10 | - cron: '0 6 * * *' # Daily 6AM UTC build 11 | 12 | 13 | jobs: 14 | 15 | lint: 16 | name: Linter 17 | runs-on: ubuntu-latest 18 | timeout-minutes: 5 19 | outputs: 20 | version: ${{ steps.version.outputs.version }} 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | - name: Setup Python 25 | uses: actions/setup-python@v4 26 | - name: Cache PyPI 27 | uses: actions/cache@v3 28 | with: 29 | key: pip-lint-${{ hashFiles('requirements*.txt') }} 30 | path: ~/.cache/pip 31 | restore-keys: | 32 | pip-lint- 33 | - name: Install dependencies 34 | uses: py-actions/py-dependency-install@v3 35 | with: 36 | path: requirements-dev.txt 37 | - name: Install itself 38 | run: | 39 | pip install . 40 | - name: Run linters 41 | run: | 42 | make lint 43 | env: 44 | CI_LINT_RUN: 1 45 | - name: Install spell checker 46 | run: | 47 | sudo apt install libenchant-dev 48 | pip install -r requirements-doc.txt 49 | - name: Run docs spelling 50 | run: | 51 | towncrier --yes 52 | make doc-spelling 53 | - name: Prepare twine checker 54 | run: | 55 | pip install -U twine wheel 56 | python setup.py sdist bdist_wheel 57 | - name: Run twine checker 58 | run: | 59 | twine check dist/* 60 | 61 | unit: 62 | name: Unit 63 | needs: [lint] 64 | strategy: 65 | matrix: 66 | python-version: ['3.7', '3.8', '3.9', '3.10'] 67 | runs-on: ubuntu-latest 68 | timeout-minutes: 10 69 | steps: 70 | - name: Checkout 71 | uses: actions/checkout@v3 72 | - name: Setup Python ${{ matrix.python-version }} 73 | uses: actions/setup-python@v4 74 | with: 75 | python-version: ${{ matrix.python-version }} 76 | - name: Get pip cache dir 77 | id: pip-cache 78 | run: | 79 | echo "::set-output name=dir::$(pip cache dir)" # - name: Cache 80 | - name: Cache PyPI 81 | uses: actions/cache@v3 82 | with: 83 | key: pip-ci-ubuntu-${{ matrix.python-version }}-${{ hashFiles('requirements*.txt') }} 84 | path: ${{ steps.pip-cache.outputs.dir }} 85 | restore-keys: | 86 | pip-ci-ubuntu-${{ matrix.python-version }}- 87 | - name: Install dependencies 88 | uses: py-actions/py-dependency-install@v3 89 | with: 90 | path: requirements-dev.txt 91 | - name: Run unittests 92 | env: 93 | COLOR: 'yes' 94 | run: | 95 | make test 96 | - name: Upload coverage 97 | uses: codecov/codecov-action@v3 98 | with: 99 | file: ./coverage.xml 100 | flags: unit 101 | fail_ci_if_error: false 102 | 103 | deploy: 104 | name: Deploy on PyPI 105 | needs: [lint, unit] 106 | runs-on: ubuntu-latest 107 | # Run only on pushing a tag 108 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 109 | steps: 110 | - name: Checkout 111 | uses: actions/checkout@v3 112 | - name: Setup Python 113 | uses: actions/setup-python@v4 114 | - name: Install dependencies 115 | run: 116 | python -m pip install -U pip wheel build twine 117 | - name: Make dists 118 | run: 119 | python -m build 120 | - name: Release 121 | uses: aio-libs/create-release@v1.6.4 122 | with: 123 | changes_file: CHANGES.rst 124 | name: aiozipkin 125 | github_token: ${{ secrets.GITHUB_TOKEN }} 126 | pypi_token: ${{ secrets.PYPI_TOKEN }} 127 | artifact: "" 128 | version_file: aiozipkin/__init__.py 129 | fix_issue_regex: "\n?\\s*`#(\\d+) `_" 130 | fix_issue_repl: " (#\\1)" 131 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request_target 3 | 4 | permissions: 5 | pull-requests: write 6 | contents: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v1.3.2 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | run: gh pr merge --auto --squash "$PR_URL" 20 | env: 21 | PR_URL: ${{github.event.pull_request.html_url}} 22 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ># Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | pyvenv/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .cache 41 | .pytest_cache 42 | nosetests.xml 43 | coverage.xml 44 | cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | 59 | # PyCharm 60 | .idea 61 | *.iml 62 | # rope 63 | *.swp 64 | .ropeproject 65 | tags 66 | node_modules/ 67 | 68 | # mypy 69 | .mypy_cache/ 70 | 71 | 72 | .python-version 73 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: 'v5.0.0' 4 | hooks: 5 | - id: check-merge-conflict 6 | - repo: https://github.com/asottile/yesqa 7 | rev: v1.5.0 8 | hooks: 9 | - id: yesqa 10 | - repo: https://github.com/PyCQA/isort 11 | rev: '5.13.2' 12 | hooks: 13 | - id: isort 14 | - repo: https://github.com/psf/black 15 | rev: '24.10.0' 16 | hooks: 17 | - id: black 18 | language_version: python3 # Should be a command that runs python3.6+ 19 | - repo: https://github.com/pre-commit/pre-commit-hooks 20 | rev: 'v5.0.0' 21 | hooks: 22 | - id: end-of-file-fixer 23 | exclude: >- 24 | ^docs/[^/]*\.svg$ 25 | - id: requirements-txt-fixer 26 | - id: trailing-whitespace 27 | - id: file-contents-sorter 28 | files: | 29 | docs/spelling_wordlist.txt| 30 | .gitignore| 31 | .gitattributes 32 | - id: check-case-conflict 33 | - id: check-json 34 | - id: check-xml 35 | - id: check-executables-have-shebangs 36 | - id: check-toml 37 | - id: check-xml 38 | - id: check-yaml 39 | - id: debug-statements 40 | - id: check-added-large-files 41 | - id: check-symlinks 42 | - id: debug-statements 43 | - id: detect-aws-credentials 44 | args: ['--allow-missing-credentials'] 45 | - id: detect-private-key 46 | - repo: https://github.com/asottile/pyupgrade 47 | rev: 'v3.19.1' 48 | hooks: 49 | - id: pyupgrade 50 | args: ['--py36-plus'] 51 | - repo: https://github.com/PyCQA/flake8 52 | rev: '7.1.1' 53 | hooks: 54 | - id: flake8 55 | exclude: "^docs/" 56 | 57 | - repo: https://github.com/Lucas-C/pre-commit-hooks-markup 58 | rev: v1.0.1 59 | hooks: 60 | - id: rst-linter 61 | files: >- 62 | ^[^/]+[.]rst$ 63 | -------------------------------------------------------------------------------- /.pyup.yml: -------------------------------------------------------------------------------- 1 | update: false 2 | label_prs: deps-update 3 | schedule: every week 4 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | CHANGES 2 | ======= 3 | 4 | .. 5 | You should *NOT* be adding new change log entries to this file, this 6 | file is managed by towncrier. You *may* edit previous change logs to 7 | fix problems like typo corrections or such. 8 | To add a new change log entry, please see 9 | https://pip.pypa.io/en/latest/development/#adding-a-news-entry 10 | we named the news folder "changes". 11 | 12 | WARNING: Don't drop the next directive! 13 | 14 | .. towncrier release notes start 15 | 16 | 1.1.1 (2021-10-23) 17 | ================== 18 | 19 | Bugfixes 20 | -------- 21 | 22 | - Fix unhandled AssertionError in aiohttp integration when unknown resource requested by the user. 23 | `#400 `_ 24 | - Fix ``NoneType`` error when using ``SystemRoute``. 25 | `#410 `_ 26 | 27 | ---- 28 | 29 | 30 | 1.1.0 (2021-05-17) 31 | ================== 32 | 33 | Bugfixes 34 | -------- 35 | 36 | - Expect trace request context to be of SimpleNamespace type. 37 | `#385 `_ 38 | 39 | 40 | ---- 41 | 42 | 43 | 1.0.0 (2020-11-06) 44 | ================== 45 | 46 | Bugfixes 47 | -------- 48 | 49 | - Support Python 3.8 and Python 3.9 50 | `#259 `_ 51 | 52 | 53 | ---- 54 | 55 | 56 | 0.7.1 (2020-09-20) 57 | ================== 58 | 59 | Bugfixes 60 | -------- 61 | 62 | - Fix `Manifest.in` file; add `CHANGES.rst` to the Source Tarball. 63 | 64 | 65 | 0.7.0 (2020-07-17) 66 | ================== 67 | 68 | Features 69 | -------- 70 | 71 | - Add support of AWS X-Ray trace id format. 72 | `#273 `_ 73 | 74 | 75 | ---- 76 | 77 | 78 | 0.6.0 (2019-10-12) 79 | ------------------ 80 | * Add context var support for python3.7 aiohttp instrumentation #187 81 | * Single header tracing support #189 82 | * Add retries and batches to transport (thanks @konstantin-stepanov) 83 | * Drop python3.5 support #238 84 | * Use new typing syntax in codebase #237 85 | 86 | 87 | 0.5.0 (2018-12-25) 88 | ------------------ 89 | * More strict typing configuration is used #147 90 | * Fixed bunch of typos in code and docs #151 #153 (thanks @deejay1) 91 | * Added interface for Transport #155 (thanks @deejay1) 92 | * Added create_custom helper for easer tracer configuration #160 (thanks @deejay1) 93 | * Added interface for Sampler #160 (thanks @deejay1) 94 | * Added py.typed marker 95 | 96 | 97 | 0.4.0 (2018-07-11) 98 | ------------------ 99 | * Add more coverage with typing #147 100 | * Breaking change: typo send_inteval => send_interval #144 (thanks @gugu) 101 | * Breaking change: do not append api/v2/spans to the zipkin dress #150 102 | 103 | 104 | 0.3.0 (2018-06-13) 105 | ------------------ 106 | * Add support http.route tag for aiohttp #138 107 | * Make zipkin address builder more permissive #141 (thanks @dsantosfff) 108 | 109 | 110 | 0.2.0 (2018-03-03) 111 | ------------------ 112 | * Breaking change: az.create is coroutine now #114 113 | * Added context manger for tracer object #114 114 | * Added more mypy types #117 115 | 116 | 117 | 0.1.1 (2018-01-26) 118 | ------------------ 119 | * Added new_child helper method #83 120 | 121 | 122 | 0.1.0 (2018-01-21) 123 | ------------------ 124 | After few months of work and beta releases here are basic features: 125 | 126 | * Initial release. 127 | * Implemented zipkin v2 protocol with HTTP transport 128 | * Added jaeger support 129 | * Added stackdriver support 130 | * Added aiohttp server support 131 | * Added aiohttp 3.0.0 client tracing support 132 | * Added examples and demos 133 | -------------------------------------------------------------------------------- /CHANGES/.TEMPLATE.rst: -------------------------------------------------------------------------------- 1 | {# TOWNCRIER TEMPLATE #} 2 | {% for section, _ in sections.items() %} 3 | {% set underline = underlines[0] %}{% if section %}{{section}} 4 | {{ underline * section|length }}{% set underline = underlines[1] %} 5 | 6 | {% endif %} 7 | 8 | {% if sections[section] %} 9 | {% for category, val in definitions.items() if category in sections[section]%} 10 | {{ definitions[category]['name'] }} 11 | {{ underline * definitions[category]['name']|length }} 12 | 13 | {% if definitions[category]['showcontent'] %} 14 | {% for text, values in sections[section][category].items() %} 15 | - {{ text }} 16 | {{ values|join(',\n ') }} 17 | {% endfor %} 18 | 19 | {% else %} 20 | - {{ sections[section][category]['']|join(', ') }} 21 | 22 | {% endif %} 23 | {% if sections[section][category]|length == 0 %} 24 | No significant changes. 25 | 26 | {% else %} 27 | {% endif %} 28 | 29 | {% endfor %} 30 | {% else %} 31 | No significant changes. 32 | 33 | 34 | {% endif %} 35 | {% endfor %} 36 | ---- 37 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Setting Development Environment 5 | ------------------------------- 6 | 7 | .. _GitHub: https://github.com/aio-libs/aiozipkin 8 | 9 | Thanks for your interest in contributing to ``aiozipkin``, there are multiple 10 | ways and places you can contribute, help on on documentation and tests is very 11 | appreciated. 12 | 13 | To setup development environment, fist of all just clone repository: 14 | 15 | .. code-block:: bash 16 | 17 | $ git clone git@github.com:aio-libs/aiozipkin.git 18 | 19 | Create virtualenv with python3.6+. For example 20 | using *virtualenvwrapper* commands could look like: 21 | 22 | .. code-block:: bash 23 | 24 | $ cd aiozipkin 25 | $ mkvirtualenv --python=`which python3.6` aiozipkin 26 | 27 | 28 | After that please install libraries required for development: 29 | 30 | .. code-block:: bash 31 | 32 | $ pip install -r requirements-dev.txt 33 | $ pip install -e . 34 | 35 | 36 | Running Tests 37 | ------------- 38 | Congratulations, you are ready to run the test suite: 39 | 40 | .. code-block:: bash 41 | 42 | $ make cov 43 | 44 | To run individual test use following command: 45 | 46 | .. code-block:: bash 47 | 48 | $ py.test -sv tests/test_tracer.py -k test_name 49 | 50 | 51 | Project uses Docker_ for integration tests, test infrastructure will 52 | automatically pull ``zipkin:2`` or ``jaegertracing/all-in-one:1.0.0`` image 53 | and start server, you don't to worry about this just make sure you 54 | have Docker_ installed. 55 | 56 | 57 | Reporting an Issue 58 | ------------------ 59 | If you have found an issue with `aiozipkin` please do 60 | not hesitate to file an issue on the GitHub_ project. When filing your 61 | issue please make sure you can express the issue with a reproducible test 62 | case. 63 | 64 | When reporting an issue we also need as much information about your environment 65 | that you can include. We never know what information will be pertinent when 66 | trying narrow down the issue. Please include at least the following 67 | information: 68 | 69 | * Version of `aiozipkin` and `python`. 70 | * Version `zipkin` server. 71 | * Platform you're running on (OS X, Linux). 72 | 73 | .. _Docker: https://docs.docker.com/engine/installation/ 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2019 Nikolay Novik 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include CHANGES.rst 3 | include README.rst 4 | graft aiozipkin 5 | global-exclude *.pyc *.swp 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Some simple testing tasks (sorry, UNIX only). 2 | 3 | FLAGS= 4 | 5 | fmt: 6 | ifdef CI_LINT_RUN 7 | pre-commit run --all-files --show-diff-on-failure 8 | else 9 | pre-commit run --all-files 10 | endif 11 | 12 | lint: bandit fmt 13 | mypy --show-error-codes --strict aiozipkin tests setup.py 14 | 15 | test: 16 | py.test -s -v $(FLAGS) ./tests/ 17 | 18 | vtest: 19 | py.test -s -v $(FLAGS) ./tests/ 20 | 21 | checkrst: 22 | python setup.py check --restructuredtext 23 | 24 | bandit: 25 | bandit -c .bandit.yml -r ./aiozipkin 26 | 27 | pyroma: 28 | pyroma -d . 29 | 30 | testloop: 31 | while true ; do \ 32 | py.test -s -v $(FLAGS) ./tests/ ; \ 33 | done 34 | 35 | cov cover coverage: flake checkrst 36 | py.test -s -v --cov-report term --cov-report html --cov aiozipkin ./tests 37 | @echo "open file://`pwd`/htmlcov/index.html" 38 | 39 | 40 | clean: 41 | rm -rf `find . -name __pycache__` 42 | rm -f `find . -type f -name '*.py[co]' ` 43 | rm -f `find . -type f -name '*~' ` 44 | rm -f `find . -type f -name '.*~' ` 45 | rm -f `find . -type f -name '@*' ` 46 | rm -f `find . -type f -name '#*#' ` 47 | rm -f `find . -type f -name '*.orig' ` 48 | rm -f `find . -type f -name '*.rej' ` 49 | rm -f .coverage 50 | rm -rf coverage 51 | rm -rf build 52 | rm -rf htmlcov 53 | rm -rf dist 54 | rm -rf node_modules 55 | 56 | docker_clean: 57 | -@docker rmi $$(docker images -q --filter "dangling=true") 58 | -@docker rm $$(docker ps -q -f status=exited) 59 | -@docker volume ls -qf dangling=true | xargs -r docker volume rm 60 | 61 | zipkin_start: 62 | docker run --name zipkin -d --rm -p 9411:9411 openzipkin/zipkin:2 63 | 64 | zipkin_stop: 65 | docker stop zipkin 66 | 67 | doc: 68 | make -C docs html 69 | @echo "open file://`pwd`/docs/_build/html/index.html" 70 | 71 | .PHONY: doc-spelling 72 | doc-spelling: 73 | @make -C docs spelling SPHINXOPTS="-W -E" 74 | 75 | 76 | setup init: 77 | pip install -r requirements-dev.txt -r requirements-doc.txt 78 | pre-commit install 79 | 80 | .PHONY: all flake test vtest cov clean doc ci 81 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | aiozipkin 2 | ========= 3 | .. image:: https://github.com/aio-libs/aiozipkin/workflows/CI/badge.svg 4 | :target: https://github.com/aio-libs/aiozipkin/actions?query=workflow%3ACI 5 | .. image:: https://codecov.io/gh/aio-libs/aiozipkin/branch/master/graph/badge.svg 6 | :target: https://codecov.io/gh/aio-libs/aiozipkin 7 | .. image:: https://api.codeclimate.com/v1/badges/1ff813d5cad2d702cbf1/maintainability 8 | :target: https://codeclimate.com/github/aio-libs/aiozipkin/maintainability 9 | :alt: Maintainability 10 | .. image:: https://img.shields.io/pypi/v/aiozipkin.svg 11 | :target: https://pypi.python.org/pypi/aiozipkin 12 | .. image:: https://readthedocs.org/projects/aiozipkin/badge/?version=latest 13 | :target: http://aiozipkin.readthedocs.io/en/latest/?badge=latest 14 | :alt: Documentation Status 15 | .. image:: https://badges.gitter.im/Join%20Chat.svg 16 | :target: https://gitter.im/aio-libs/Lobby 17 | :alt: Chat on Gitter 18 | 19 | **aiozipkin** is Python 3.6+ module that adds distributed tracing capabilities 20 | from asyncio_ applications with zipkin (http://zipkin.io) server instrumentation. 21 | 22 | zipkin_ is a distributed tracing system. It helps gather timing data needed 23 | to troubleshoot latency problems in microservice architectures. It manages 24 | both the collection and lookup of this data. Zipkin’s design is based on 25 | the Google Dapper paper. 26 | 27 | Applications are instrumented with **aiozipkin** report timing data to zipkin_. 28 | The Zipkin UI also presents a Dependency diagram showing how many traced 29 | requests went through each application. If you are troubleshooting latency 30 | problems or errors, you can filter or sort all traces based on the 31 | application, length of trace, annotation, or timestamp. 32 | 33 | .. image:: https://raw.githubusercontent.com/aio-libs/aiozipkin/master/docs/zipkin_animation2.gif 34 | :alt: zipkin ui animation 35 | 36 | 37 | Features 38 | ======== 39 | * Distributed tracing capabilities to **asyncio** applications. 40 | * Support zipkin_ ``v2`` protocol. 41 | * Easy to use API. 42 | * Explicit context handling, no thread local variables. 43 | * Can work with jaeger_ and stackdriver_ through zipkin compatible API. 44 | 45 | 46 | zipkin vocabulary 47 | ----------------- 48 | Before code lets learn important zipkin_ vocabulary, for more detailed 49 | information please visit https://zipkin.io/pages/instrumenting 50 | 51 | .. image:: https://raw.githubusercontent.com/aio-libs/aiozipkin/master/docs/zipkin_glossary.png 52 | :alt: zipkin ui glossary 53 | 54 | * **Span** represents one specific method (RPC) call 55 | * **Annotation** string data associated with a particular timestamp in span 56 | * **Tag** - key and value associated with given span 57 | * **Trace** - collection of spans, related to serving particular request 58 | 59 | 60 | Simple example 61 | -------------- 62 | 63 | .. code:: python 64 | 65 | import asyncio 66 | import aiozipkin as az 67 | 68 | 69 | async def run(): 70 | # setup zipkin client 71 | zipkin_address = 'http://127.0.0.1:9411/api/v2/spans' 72 | endpoint = az.create_endpoint( 73 | "simple_service", ipv4="127.0.0.1", port=8080) 74 | tracer = await az.create(zipkin_address, endpoint, sample_rate=1.0) 75 | 76 | # create and setup new trace 77 | with tracer.new_trace(sampled=True) as span: 78 | # give a name for the span 79 | span.name("Slow SQL") 80 | # tag with relevant information 81 | span.tag("span_type", "root") 82 | # indicate that this is client span 83 | span.kind(az.CLIENT) 84 | # make timestamp and name it with START SQL query 85 | span.annotate("START SQL SELECT * FROM") 86 | # imitate long SQL query 87 | await asyncio.sleep(0.1) 88 | # make other timestamp and name it "END SQL" 89 | span.annotate("END SQL") 90 | 91 | await tracer.close() 92 | 93 | if __name__ == "__main__": 94 | loop = asyncio.get_event_loop() 95 | loop.run_until_complete(run()) 96 | 97 | 98 | aiohttp example 99 | --------------- 100 | 101 | *aiozipkin* includes *aiohttp* server instrumentation, for this create 102 | `web.Application()` as usual and install aiozipkin plugin: 103 | 104 | 105 | .. code:: python 106 | 107 | import aiozipkin as az 108 | 109 | def init_app(): 110 | host, port = "127.0.0.1", 8080 111 | app = web.Application() 112 | endpoint = az.create_endpoint("AIOHTTP_SERVER", ipv4=host, port=port) 113 | tracer = await az.create(zipkin_address, endpoint, sample_rate=1.0) 114 | az.setup(app, tracer) 115 | 116 | 117 | That is it, plugin adds middleware that tries to fetch context from headers, 118 | and create/join new trace. Optionally on client side you can add propagation 119 | headers in order to force tracing and to see network latency between client and 120 | server. 121 | 122 | .. code:: python 123 | 124 | import aiozipkin as az 125 | 126 | endpoint = az.create_endpoint("AIOHTTP_CLIENT") 127 | tracer = await az.create(zipkin_address, endpoint) 128 | 129 | with tracer.new_trace() as span: 130 | span.kind(az.CLIENT) 131 | headers = span.context.make_headers() 132 | host = "http://127.0.0.1:8080/api/v1/posts/{}".format(i) 133 | resp = await session.get(host, headers=headers) 134 | await resp.text() 135 | 136 | 137 | Documentation 138 | ------------- 139 | http://aiozipkin.readthedocs.io/ 140 | 141 | 142 | Installation 143 | ------------ 144 | Installation process is simple, just:: 145 | 146 | $ pip install aiozipkin 147 | 148 | 149 | Support of other collectors 150 | =========================== 151 | **aiozipkin** can work with any other zipkin_ compatible service, currently we 152 | tested it with jaeger_ and stackdriver_. 153 | 154 | 155 | Jaeger support 156 | -------------- 157 | jaeger_ supports zipkin_ span format as result it is possible to use *aiozipkin* 158 | with jaeger_ server. You just need to specify *jaeger* server address and it 159 | should work out of the box. Not need to run local zipkin server. 160 | For more informations see tests and jaeger_ documentation. 161 | 162 | .. image:: https://raw.githubusercontent.com/aio-libs/aiozipkin/master/docs/jaeger.png 163 | :alt: jaeger ui animation 164 | 165 | 166 | Stackdriver support 167 | ------------------- 168 | Google stackdriver_ supports zipkin_ span format as result it is possible to 169 | use *aiozipkin* with this google_ service. In order to make this work you 170 | need to setup zipkin service locally, that will send trace to the cloud. See 171 | google_ cloud documentation how to setup make zipkin collector: 172 | 173 | .. image:: https://raw.githubusercontent.com/aio-libs/aiozipkin/master/docs/stackdriver.png 174 | :alt: jaeger ui animation 175 | 176 | 177 | Requirements 178 | ------------ 179 | 180 | * Python_ 3.6+ 181 | * aiohttp_ 182 | 183 | 184 | .. _PEP492: https://www.python.org/dev/peps/pep-0492/ 185 | .. _Python: https://www.python.org 186 | .. _aiohttp: https://github.com/KeepSafe/aiohttp 187 | .. _asyncio: http://docs.python.org/3.5/library/asyncio.html 188 | .. _uvloop: https://github.com/MagicStack/uvloop 189 | .. _zipkin: http://zipkin.io 190 | .. _jaeger: http://jaeger.readthedocs.io/en/latest/ 191 | .. _stackdriver: https://cloud.google.com/stackdriver/ 192 | .. _google: https://cloud.google.com/trace/docs/zipkin 193 | -------------------------------------------------------------------------------- /aiozipkin/__init__.py: -------------------------------------------------------------------------------- 1 | from .aiohttp_helpers import ( 2 | APP_AIOZIPKIN_KEY, 3 | REQUEST_AIOZIPKIN_KEY, 4 | get_tracer, 5 | make_trace_config, 6 | middleware_maker, 7 | request_span, 8 | setup, 9 | ) 10 | from .constants import ( 11 | HTTP_HOST, 12 | HTTP_METHOD, 13 | HTTP_PATH, 14 | HTTP_REQUEST_SIZE, 15 | HTTP_RESPONSE_SIZE, 16 | HTTP_ROUTE, 17 | HTTP_STATUS_CODE, 18 | HTTP_URL, 19 | ) 20 | from .helpers import CLIENT, CONSUMER, PRODUCER, SERVER, create_endpoint, make_context 21 | from .sampler import Sampler 22 | from .span import SpanAbc 23 | from .tracer import Tracer, create, create_custom 24 | 25 | 26 | __version__ = "1.1.1" 27 | __all__ = ( 28 | "Tracer", 29 | "Sampler", 30 | "SpanAbc", 31 | "create", 32 | "create_custom", 33 | "create_endpoint", 34 | "make_context", 35 | # aiohttp helpers 36 | "setup", 37 | "get_tracer", 38 | "request_span", 39 | "middleware_maker", 40 | "make_trace_config", 41 | "APP_AIOZIPKIN_KEY", 42 | "REQUEST_AIOZIPKIN_KEY", 43 | # possible span kinds 44 | "CLIENT", 45 | "SERVER", 46 | "PRODUCER", 47 | "CONSUMER", 48 | # constants 49 | "HTTP_HOST", 50 | "HTTP_METHOD", 51 | "HTTP_PATH", 52 | "HTTP_REQUEST_SIZE", 53 | "HTTP_RESPONSE_SIZE", 54 | "HTTP_STATUS_CODE", 55 | "HTTP_URL", 56 | "HTTP_ROUTE", 57 | ) 58 | -------------------------------------------------------------------------------- /aiozipkin/aiohttp_helpers.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import sys 3 | from contextlib import contextmanager 4 | from types import SimpleNamespace 5 | from typing import ( 6 | Any, 7 | Awaitable, 8 | Callable, 9 | Dict, 10 | Generator, 11 | Iterable, 12 | Optional, 13 | Set, 14 | cast, 15 | ) 16 | 17 | import aiohttp 18 | from aiohttp import ( 19 | TraceRequestEndParams, 20 | TraceRequestExceptionParams, 21 | TraceRequestStartParams, 22 | ) 23 | from aiohttp.web import ( 24 | AbstractRoute, 25 | Application, 26 | HTTPException, 27 | Request, 28 | StreamResponse, 29 | middleware, 30 | ) 31 | 32 | from .constants import HTTP_METHOD, HTTP_PATH, HTTP_ROUTE, HTTP_STATUS_CODE 33 | from .helpers import ( 34 | CLIENT, 35 | SERVER, 36 | TraceContext, 37 | make_context, 38 | parse_debug_header, 39 | parse_sampled_header, 40 | ) 41 | from .span import SpanAbc 42 | from .tracer import Tracer 43 | 44 | 45 | APP_AIOZIPKIN_KEY = "aiozipkin_tracer" 46 | REQUEST_AIOZIPKIN_KEY = "aiozipkin_span" 47 | 48 | 49 | __all__ = ( 50 | "setup", 51 | "get_tracer", 52 | "request_span", 53 | "middleware_maker", 54 | "make_trace_config", 55 | "APP_AIOZIPKIN_KEY", 56 | "REQUEST_AIOZIPKIN_KEY", 57 | ) 58 | 59 | Handler = Callable[[Request], Awaitable[StreamResponse]] 60 | Middleware = Callable[[Request, Handler], Awaitable[StreamResponse]] 61 | 62 | 63 | def _set_remote_endpoint(span: SpanAbc, request: Request) -> None: 64 | peername = request.remote 65 | if peername is not None: 66 | kwargs: Dict[str, Any] = {} 67 | try: 68 | peer_ipaddress = ipaddress.ip_address(peername) 69 | except ValueError: 70 | pass 71 | else: 72 | if isinstance(peer_ipaddress, ipaddress.IPv4Address): 73 | kwargs["ipv4"] = str(peer_ipaddress) 74 | else: 75 | kwargs["ipv6"] = str(peer_ipaddress) 76 | if kwargs: 77 | span.remote_endpoint(None, **kwargs) 78 | 79 | 80 | def _get_span(request: Request, tracer: Tracer) -> SpanAbc: 81 | # builds span from incoming request, if no context found, create 82 | # new span 83 | context = make_context(request.headers) 84 | 85 | if context is None: 86 | sampled = parse_sampled_header(request.headers) 87 | debug = parse_debug_header(request.headers) 88 | span = tracer.new_trace(sampled=sampled, debug=debug) 89 | else: 90 | span = tracer.join_span(context) 91 | return span 92 | 93 | 94 | def _set_span_properties(span: SpanAbc, request: Request) -> None: 95 | span_name = f"{request.method.upper()} {request.path}" 96 | span.name(span_name) 97 | span.kind(SERVER) 98 | span.tag(HTTP_PATH, request.path) 99 | span.tag(HTTP_METHOD, request.method.upper()) 100 | 101 | resource = request.match_info.route.resource 102 | if resource is not None: 103 | route = resource.canonical 104 | span.tag(HTTP_ROUTE, route) 105 | 106 | _set_remote_endpoint(span, request) 107 | 108 | 109 | PY37 = sys.version_info >= (3, 7) 110 | 111 | if PY37: 112 | from contextvars import ContextVar 113 | 114 | OptTraceVar = ContextVar[Optional[TraceContext]] 115 | zipkin_context: OptTraceVar = ContextVar("zipkin_context", default=None) 116 | 117 | @contextmanager 118 | def set_context_value( 119 | context_var: OptTraceVar, value: TraceContext 120 | ) -> Generator[OptTraceVar, None, None]: 121 | token = context_var.set(value) 122 | try: 123 | yield context_var 124 | finally: 125 | context_var.reset(token) 126 | 127 | 128 | def middleware_maker( 129 | skip_routes: Optional[Iterable[AbstractRoute]] = None, 130 | tracer_key: str = APP_AIOZIPKIN_KEY, 131 | request_key: str = REQUEST_AIOZIPKIN_KEY, 132 | ) -> Middleware: 133 | s = skip_routes 134 | skip_routes_set: Set[AbstractRoute] = set(s) if s else set() 135 | 136 | @middleware 137 | async def aiozipkin_middleware( 138 | request: Request, handler: Handler 139 | ) -> StreamResponse: 140 | # route is in skip list, we do not track anything with zipkin 141 | if request.match_info.route in skip_routes_set: 142 | resp = await handler(request) 143 | return resp 144 | 145 | tracer = request.app[tracer_key] 146 | span = _get_span(request, tracer) 147 | request[request_key] = span 148 | if span.is_noop: 149 | resp = await handler(request) 150 | return resp 151 | 152 | if PY37: 153 | with set_context_value(zipkin_context, span.context): 154 | with span: 155 | _set_span_properties(span, request) 156 | try: 157 | resp = await handler(request) 158 | except HTTPException as e: 159 | span.tag(HTTP_STATUS_CODE, str(e.status)) 160 | raise 161 | span.tag(HTTP_STATUS_CODE, str(resp.status)) 162 | else: 163 | with span: 164 | _set_span_properties(span, request) 165 | try: 166 | resp = await handler(request) 167 | except HTTPException as e: 168 | span.tag(HTTP_STATUS_CODE, str(e.status)) 169 | raise 170 | span.tag(HTTP_STATUS_CODE, str(resp.status)) 171 | 172 | return resp 173 | 174 | return aiozipkin_middleware 175 | 176 | 177 | def setup( 178 | app: Application, 179 | tracer: Tracer, 180 | *, 181 | skip_routes: Optional[Iterable[AbstractRoute]] = None, 182 | tracer_key: str = APP_AIOZIPKIN_KEY, 183 | request_key: str = REQUEST_AIOZIPKIN_KEY, 184 | ) -> Application: 185 | """Sets required parameters in aiohttp applications for aiozipkin. 186 | 187 | Tracer added into application context and cleaned after application 188 | shutdown. You can provide custom tracer_key, if default name is not 189 | suitable. 190 | """ 191 | app[tracer_key] = tracer 192 | m = middleware_maker( 193 | skip_routes=skip_routes, tracer_key=tracer_key, request_key=request_key 194 | ) 195 | app.middlewares.append(m) 196 | 197 | # register cleanup signal to close zipkin transport connections 198 | async def close_aiozipkin(app: Application) -> None: 199 | await app[tracer_key].close() 200 | 201 | app.on_cleanup.append(close_aiozipkin) 202 | 203 | return app 204 | 205 | 206 | def get_tracer(app: Application, tracer_key: str = APP_AIOZIPKIN_KEY) -> Tracer: 207 | """Returns tracer object from application context. 208 | 209 | By default tracer has APP_AIOZIPKIN_KEY in aiohttp application context, 210 | you can provide own key, if for some reason default one is not suitable. 211 | """ 212 | return cast(Tracer, app[tracer_key]) 213 | 214 | 215 | def request_span(request: Request, request_key: str = REQUEST_AIOZIPKIN_KEY) -> SpanAbc: 216 | """Returns span created by middleware from request context, you can use it 217 | as parent on next child span. 218 | """ 219 | return cast(SpanAbc, request[request_key]) 220 | 221 | 222 | class ZipkinClientSignals: 223 | """Class contains signal handler for aiohttp client. Handlers executed 224 | only if aiohttp session contains tracer context with span. 225 | """ 226 | 227 | def __init__(self, tracer: Tracer) -> None: 228 | self._tracer = tracer 229 | 230 | def _get_span_context( 231 | self, trace_config_ctx: SimpleNamespace 232 | ) -> Optional[TraceContext]: 233 | ctx = self._get_span_context_from_dict( 234 | trace_config_ctx 235 | ) or self._get_span_context_from_namespace(trace_config_ctx) 236 | 237 | if ctx: 238 | return ctx 239 | 240 | if PY37: 241 | has_implicit_context = zipkin_context.get() is not None 242 | if has_implicit_context: 243 | return zipkin_context.get() 244 | 245 | return None 246 | 247 | def _get_span_context_from_dict( 248 | self, trace_config_ctx: SimpleNamespace 249 | ) -> Optional[TraceContext]: 250 | ctx = trace_config_ctx.trace_request_ctx 251 | 252 | if isinstance(ctx, dict): 253 | r: Optional[TraceContext] = ctx.get("span_context") 254 | return r 255 | 256 | return None 257 | 258 | def _get_span_context_from_namespace( 259 | self, trace_config_ctx: SimpleNamespace 260 | ) -> Optional[TraceContext]: 261 | ctx = trace_config_ctx.trace_request_ctx 262 | 263 | if isinstance(ctx, SimpleNamespace): 264 | r: Optional[TraceContext] = getattr(ctx, "span_context", None) 265 | return r 266 | 267 | return None 268 | 269 | async def on_request_start( 270 | self, 271 | session: aiohttp.ClientSession, 272 | context: SimpleNamespace, 273 | params: TraceRequestStartParams, 274 | ) -> None: 275 | span_context = self._get_span_context(context) 276 | if span_context is None: 277 | return 278 | 279 | p = params 280 | span = self._tracer.new_child(span_context) 281 | context._span = span 282 | span.start() 283 | span_name = f"client {p.method.upper()} {p.url.path}" 284 | span.name(span_name) 285 | span.kind(CLIENT) 286 | 287 | ctx = context.trace_request_ctx 288 | propagate_headers = True 289 | 290 | if isinstance(ctx, dict): 291 | # Check ctx is dict to be compatible with old package versions 292 | propagate_headers = ctx.get("propagate_headers", True) 293 | if isinstance(ctx, SimpleNamespace): 294 | propagate_headers = getattr(ctx, "propagate_headers", True) 295 | 296 | if propagate_headers: 297 | span_headers = span.context.make_headers() 298 | p.headers.update(span_headers) 299 | 300 | async def on_request_end( 301 | self, 302 | session: aiohttp.ClientSession, 303 | context: SimpleNamespace, 304 | params: TraceRequestEndParams, 305 | ) -> None: 306 | span_context = self._get_span_context(context) 307 | if span_context is None: 308 | return 309 | 310 | span = context._span 311 | span.finish() 312 | delattr(context, "_span") 313 | 314 | async def on_request_exception( 315 | self, 316 | session: aiohttp.ClientSession, 317 | context: SimpleNamespace, 318 | params: TraceRequestExceptionParams, 319 | ) -> None: 320 | 321 | span_context = self._get_span_context(context) 322 | if span_context is None: 323 | return 324 | span = context._span 325 | span.finish(exception=params.exception) 326 | delattr(context, "_span") 327 | 328 | 329 | def make_trace_config(tracer: Tracer) -> aiohttp.TraceConfig: 330 | """Creates aiohttp.TraceConfig with enabled aiozipking instrumentation 331 | for aiohttp client. 332 | """ 333 | trace_config = aiohttp.TraceConfig() 334 | zipkin = ZipkinClientSignals(tracer) 335 | 336 | trace_config.on_request_start.append(zipkin.on_request_start) 337 | trace_config.on_request_end.append(zipkin.on_request_end) 338 | trace_config.on_request_exception.append(zipkin.on_request_exception) 339 | return trace_config 340 | -------------------------------------------------------------------------------- /aiozipkin/constants.py: -------------------------------------------------------------------------------- 1 | # standard tags (binary annotations) 2 | HTTP_HOST = "http.host" 3 | HTTP_METHOD = "http.method" 4 | HTTP_PATH = "http.path" 5 | HTTP_REQUEST_SIZE = "http.request.size" 6 | HTTP_RESPONSE_SIZE = "http.response.size" 7 | HTTP_STATUS_CODE = "http.status_code" 8 | HTTP_URL = "http.url" 9 | HTTP_ROUTE = "http.route" 10 | 11 | ERROR = "error" 12 | LOCAL_COMPONENT = "lc" 13 | 14 | CLIENT_ADDR = "ca" 15 | MESSAGE_ADDR = "ma" 16 | SERVER_ADDR = "sa" 17 | -------------------------------------------------------------------------------- /aiozipkin/context_managers.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable as AbcAwaitable 2 | from types import TracebackType 3 | from typing import ( 4 | TYPE_CHECKING, 5 | Any, 6 | AsyncContextManager, 7 | Awaitable, 8 | Generator, 9 | Generic, 10 | Optional, 11 | Type, 12 | TypeVar, 13 | ) 14 | 15 | 16 | T = TypeVar("T", bound=AsyncContextManager["T"]) # type: ignore 17 | 18 | 19 | if TYPE_CHECKING: 20 | 21 | class _Base(AsyncContextManager[T], AbcAwaitable[T]): 22 | pass 23 | 24 | else: 25 | 26 | class _Base(Generic[T], AsyncContextManager, AbcAwaitable): 27 | pass 28 | 29 | 30 | class _ContextManager(_Base[T]): 31 | 32 | __slots__ = ("_coro", "_obj") 33 | 34 | def __init__(self, coro: Awaitable[T]) -> None: 35 | super().__init__() 36 | self._coro: Awaitable[T] = coro 37 | self._obj: Optional[T] = None 38 | 39 | def __await__(self) -> Generator[Any, None, T]: 40 | return self._coro.__await__() 41 | 42 | async def __aenter__(self) -> T: 43 | self._obj = await self._coro 44 | t: T = await self._obj.__aenter__() 45 | return t 46 | 47 | async def __aexit__( 48 | self, 49 | exc_type: Optional[Type[BaseException]], 50 | exc: Optional[BaseException], 51 | tb: Optional[TracebackType], 52 | ) -> Optional[bool]: 53 | if self._obj is None: 54 | raise RuntimeError("__aexit__ called before __aenter__") 55 | return await self._obj.__aexit__(exc_type, exc, tb) 56 | -------------------------------------------------------------------------------- /aiozipkin/helpers.py: -------------------------------------------------------------------------------- 1 | import time 2 | from typing import Any, Dict, List, NamedTuple, Optional 3 | 4 | from .mypy_types import Headers, OptBool, OptInt, OptStr, OptTs 5 | 6 | 7 | # possible span kinds 8 | CLIENT = "CLIENT" 9 | SERVER = "SERVER" 10 | PRODUCER = "PRODUCER" 11 | CONSUMER = "CONSUMER" 12 | 13 | # zipkin headers, for more information see: 14 | # https://github.com/openzipkin/b3-propagation 15 | 16 | TRACE_ID_HEADER = "X-B3-TraceId" 17 | SPAN_ID_HEADER = "X-B3-SpanId" 18 | PARENT_ID_HEADER = "X-B3-ParentSpanId" 19 | FLAGS_HEADER = "X-B3-Flags" 20 | SAMPLED_ID_HEADER = "X-B3-Sampled" 21 | SINGLE_HEADER = "b3" 22 | DELIMITER = "-" 23 | DEBUG_MARKER = "d" 24 | 25 | 26 | class _TraceContext(NamedTuple): 27 | trace_id: str 28 | parent_id: OptStr 29 | span_id: str 30 | sampled: OptBool 31 | debug: bool 32 | shared: bool 33 | 34 | 35 | class TraceContext(_TraceContext): 36 | """Immutable class with trace related data that travels across 37 | process boundaries. 38 | """ 39 | 40 | def make_headers(self) -> Headers: 41 | """Creates dict with zipkin headers from available context. 42 | 43 | Resulting dict should be passed to HTTP client propagate contest 44 | to other services. 45 | """ 46 | return make_headers(self) 47 | 48 | def make_single_header(self) -> Headers: 49 | return make_single_header(self) 50 | 51 | 52 | class Endpoint(NamedTuple): 53 | serviceName: OptStr 54 | ipv4: OptStr 55 | ipv6: OptStr 56 | port: OptInt 57 | 58 | 59 | def create_endpoint( 60 | service_name: str, *, ipv4: OptStr = None, ipv6: OptStr = None, port: OptInt = None 61 | ) -> Endpoint: 62 | """Factory function to create Endpoint object.""" 63 | return Endpoint(service_name, ipv4, ipv6, port) 64 | 65 | 66 | def make_timestamp(ts: OptTs = None) -> int: 67 | """Create zipkin timestamp in microseconds, or convert available one 68 | from second. Useful when user supplies ts from time.time() call. 69 | """ 70 | ts = ts if ts is not None else time.time() 71 | return int(ts * 1000 * 1000) # microseconds 72 | 73 | 74 | def make_headers(context: TraceContext) -> Headers: 75 | """Creates dict with zipkin headers from supplied trace context.""" 76 | headers = { 77 | TRACE_ID_HEADER: context.trace_id, 78 | SPAN_ID_HEADER: context.span_id, 79 | FLAGS_HEADER: "0", 80 | SAMPLED_ID_HEADER: "1" if context.sampled else "0", 81 | } 82 | if context.parent_id is not None: 83 | headers[PARENT_ID_HEADER] = context.parent_id 84 | return headers 85 | 86 | 87 | def make_single_header(context: TraceContext) -> Headers: 88 | """Creates dict with zipkin single header format.""" 89 | # b3={TraceId}-{SpanId}-{SamplingState}-{ParentSpanId} 90 | c = context 91 | 92 | # encode sampled flag 93 | if c.debug: 94 | sampled = "d" 95 | elif c.sampled: 96 | sampled = "1" 97 | else: 98 | sampled = "0" 99 | 100 | params: List[str] = [c.trace_id, c.span_id, sampled] 101 | if c.parent_id is not None: 102 | params.append(c.parent_id) 103 | 104 | h = DELIMITER.join(params) 105 | headers = {SINGLE_HEADER: h} 106 | return headers 107 | 108 | 109 | def parse_sampled_header(headers: Headers) -> OptBool: 110 | sampled = headers.get(SAMPLED_ID_HEADER.lower(), None) 111 | if sampled is None or sampled == "": 112 | return None 113 | return True if sampled == "1" else False 114 | 115 | 116 | def parse_debug_header(headers: Headers) -> bool: 117 | return True if headers.get(FLAGS_HEADER, "0") == "1" else False 118 | 119 | 120 | def _parse_parent_id(parts: List[str]) -> OptStr: 121 | # parse parent_id part from zipkin single header propagation 122 | parent_id = None 123 | if len(parts) >= 4: 124 | parent_id = parts[3] 125 | return parent_id 126 | 127 | 128 | def _parse_debug(parts: List[str]) -> bool: 129 | # parse debug part from zipkin single header propagation 130 | debug = False 131 | if len(parts) >= 3 and parts[2] == DEBUG_MARKER: 132 | debug = True 133 | return debug 134 | 135 | 136 | def _parse_sampled(parts: List[str]) -> OptBool: 137 | # parse sampled part from zipkin single header propagation 138 | sampled: OptBool = None 139 | if len(parts) >= 3: 140 | if parts[2] in ("1", "0"): 141 | sampled = bool(int(parts[2])) 142 | return sampled 143 | 144 | 145 | def _parse_single_header(headers: Headers) -> Optional[TraceContext]: 146 | # Makes TraceContext from zipkin single header format. 147 | # https://github.com/openzipkin/b3-propagation 148 | 149 | # b3={TraceId}-{SpanId}-{SamplingState}-{ParentSpanId} 150 | if headers[SINGLE_HEADER] == "0": 151 | return None 152 | payload = headers[SINGLE_HEADER].lower() 153 | parts: List[str] = payload.split(DELIMITER) 154 | if len(parts) < 2: 155 | return None 156 | 157 | debug = _parse_debug(parts) 158 | sampled = debug if debug else _parse_sampled(parts) 159 | 160 | context = TraceContext( 161 | trace_id=parts[0], 162 | span_id=parts[1], 163 | parent_id=_parse_parent_id(parts), 164 | sampled=sampled, 165 | debug=debug, 166 | shared=False, 167 | ) 168 | return context 169 | 170 | 171 | def make_context(headers: Headers) -> Optional[TraceContext]: 172 | """Converts available headers to TraceContext, if headers mapping does 173 | not contain zipkin headers, function returns None. 174 | """ 175 | # TODO: add validation for trace_id/span_id/parent_id 176 | 177 | # normalize header names just in case someone passed regular dict 178 | # instead dict with case insensitive keys 179 | headers = {k.lower(): v for k, v in headers.items()} 180 | 181 | required = (TRACE_ID_HEADER.lower(), SPAN_ID_HEADER.lower()) 182 | has_b3 = all(h in headers for h in required) 183 | has_b3_single = SINGLE_HEADER in headers 184 | 185 | if not (has_b3_single or has_b3): 186 | return None 187 | 188 | if has_b3: 189 | debug = parse_debug_header(headers) 190 | sampled = debug if debug else parse_sampled_header(headers) 191 | context = TraceContext( 192 | trace_id=headers[TRACE_ID_HEADER.lower()], 193 | parent_id=headers.get(PARENT_ID_HEADER.lower()), 194 | span_id=headers[SPAN_ID_HEADER.lower()], 195 | sampled=sampled, 196 | debug=debug, 197 | shared=False, 198 | ) 199 | return context 200 | return _parse_single_header(headers) 201 | 202 | 203 | OptKeys = Optional[List[str]] 204 | 205 | 206 | def filter_none(data: Dict[str, Any], keys: OptKeys = None) -> Dict[str, Any]: 207 | """Filter keys from dict with None values. 208 | 209 | Check occurs only on root level. If list of keys specified, filter 210 | works only for selected keys 211 | """ 212 | 213 | def limited_filter(k: str, v: Any) -> bool: 214 | return k not in keys or v is not None # type: ignore 215 | 216 | def full_filter(k: str, v: Any) -> bool: 217 | return v is not None 218 | 219 | f = limited_filter if keys is not None else full_filter 220 | return {k: v for k, v in data.items() if f(k, v)} 221 | -------------------------------------------------------------------------------- /aiozipkin/log.py: -------------------------------------------------------------------------------- 1 | """Logging configuration.""" 2 | 3 | import logging 4 | 5 | 6 | # Name the logger after the package. 7 | logger = logging.getLogger(__package__) 8 | -------------------------------------------------------------------------------- /aiozipkin/mypy_types.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Mapping, Optional 3 | 4 | 5 | Headers = Mapping[str, str] 6 | OptStr = Optional[str] 7 | OptTs = Optional[float] 8 | OptInt = Optional[int] 9 | OptBool = Optional[bool] 10 | OptLoop = Optional[asyncio.AbstractEventLoop] 11 | -------------------------------------------------------------------------------- /aiozipkin/py.typed: -------------------------------------------------------------------------------- 1 | # placeholder 2 | -------------------------------------------------------------------------------- /aiozipkin/record.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, List, NamedTuple, Optional, TypeVar 2 | 3 | from .helpers import CONSUMER, PRODUCER, Endpoint, TraceContext, filter_none 4 | from .mypy_types import OptInt, OptStr 5 | 6 | 7 | class Annotation(NamedTuple): 8 | value: str 9 | timestamp: int 10 | 11 | 12 | def _endpoint_asdict(endpoint: Endpoint) -> Dict[str, Any]: 13 | return filter_none(endpoint._asdict()) 14 | 15 | 16 | T = TypeVar("T", bound="Record") 17 | 18 | 19 | class Record: 20 | def __init__(self: T, context: TraceContext, local_endpoint: Endpoint) -> None: 21 | self._context = context 22 | self._local_endpoint = _endpoint_asdict(local_endpoint) 23 | self._finished = False 24 | 25 | self._name = "unknown" 26 | self._kind: OptStr = None 27 | self._timestamp: OptInt = None 28 | self._duration: OptInt = None 29 | self._remote_endpoint: Optional[Dict[str, Any]] = None 30 | self._annotations: List[Annotation] = [] 31 | self._tags: Dict[str, str] = {} 32 | 33 | @property 34 | def context(self) -> TraceContext: 35 | return self._context 36 | 37 | def start(self: T, ts: int) -> T: 38 | self._timestamp = ts 39 | return self 40 | 41 | def finish(self: T, ts: OptInt) -> T: 42 | if self._finished: 43 | return self 44 | if self._timestamp is None: 45 | raise RuntimeError("Record should be started first") 46 | if ts is not None and self._kind not in (PRODUCER, CONSUMER): 47 | self._duration = max(ts - self._timestamp, 1) 48 | self._finished = True 49 | return self 50 | 51 | def name(self: T, n: str) -> T: 52 | self._name = n 53 | return self 54 | 55 | def set_tag(self: T, key: str, value: Any) -> T: 56 | self._tags[key] = str(value) 57 | return self 58 | 59 | def annotate(self: T, value: Optional[str], ts: int) -> T: 60 | self._annotations.append(Annotation(str(value), int(ts))) 61 | return self 62 | 63 | def kind(self: T, kind: str) -> T: 64 | self._kind = kind 65 | return self 66 | 67 | def remote_endpoint(self: T, endpoint: Endpoint) -> T: 68 | self._remote_endpoint = _endpoint_asdict(endpoint) 69 | return self 70 | 71 | def asdict(self) -> Dict[str, Any]: 72 | c = self._context 73 | rec = { 74 | "traceId": c.trace_id, 75 | "name": self._name, 76 | "parentId": c.parent_id, 77 | "id": c.span_id, 78 | "kind": self._kind, 79 | "timestamp": self._timestamp, 80 | "duration": self._duration, 81 | "debug": c.debug, 82 | "shared": c.shared, 83 | "localEndpoint": self._local_endpoint, 84 | "remoteEndpoint": self._remote_endpoint, 85 | "annotations": [a._asdict() for a in self._annotations], 86 | "tags": self._tags, 87 | } 88 | return filter_none(rec, ["kind"]) 89 | -------------------------------------------------------------------------------- /aiozipkin/sampler.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from random import Random 3 | 4 | from .mypy_types import OptInt 5 | 6 | 7 | class SamplerABC(abc.ABC): 8 | @abc.abstractmethod 9 | def is_sampled(self, trace_id: str) -> bool: # pragma: no cover 10 | """Defines if given trace should be recorded for further actions.""" 11 | pass 12 | 13 | 14 | class Sampler(SamplerABC): 15 | def __init__(self, *, sample_rate: float = 1.0, seed: OptInt = None) -> None: 16 | self._sample_rate = sample_rate 17 | self._rng = Random(seed) 18 | 19 | def is_sampled(self, trace_id: str) -> bool: 20 | if self._sample_rate == 0.0: 21 | sampled = False 22 | else: 23 | sampled = self._rng.random() <= self._sample_rate 24 | return sampled 25 | 26 | 27 | # TODO: implement other types of sampler for example hash trace_id 28 | -------------------------------------------------------------------------------- /aiozipkin/span.py: -------------------------------------------------------------------------------- 1 | from abc import ABCMeta, abstractmethod 2 | from types import TracebackType 3 | from typing import TYPE_CHECKING, List, Optional, Type, TypeVar 4 | 5 | from .constants import ERROR 6 | from .helpers import Endpoint, TraceContext, make_timestamp 7 | from .mypy_types import OptInt, OptStr, OptTs 8 | from .record import Record 9 | 10 | 11 | if TYPE_CHECKING: 12 | from .tracer import Tracer 13 | 14 | 15 | T = TypeVar("T", bound="SpanAbc") 16 | 17 | 18 | class SpanAbc(metaclass=ABCMeta): 19 | @property 20 | @abstractmethod 21 | def is_noop(self: T) -> bool: 22 | return True # pragma: no cover 23 | 24 | @property 25 | @abstractmethod 26 | def context(self: T) -> TraceContext: 27 | pass # pragma: no cover 28 | 29 | @property 30 | @abstractmethod 31 | def tracer(self: T) -> "Tracer": 32 | pass # pragma: no cover 33 | 34 | @abstractmethod 35 | def start(self: T, ts: OptTs = None) -> T: 36 | pass # pragma: no cover 37 | 38 | @abstractmethod 39 | def finish(self: T, ts: OptTs = None, exception: Optional[Exception] = None) -> T: 40 | pass # pragma: no cover 41 | 42 | @abstractmethod 43 | def remote_endpoint( 44 | self: T, 45 | servce_name: OptStr, 46 | *, 47 | ipv4: OptStr = None, 48 | ipv6: OptStr = None, 49 | port: OptInt = None 50 | ) -> T: 51 | pass # pragma: no cover 52 | 53 | @abstractmethod 54 | def tag(self: T, key: str, value: str) -> T: 55 | pass # pragma: no cover 56 | 57 | @abstractmethod 58 | def annotate(self: T, value: Optional[str], ts: OptTs = None) -> T: 59 | pass # pragma: no cover 60 | 61 | @abstractmethod 62 | def kind(self: T, span_kind: str) -> T: 63 | pass # pragma: no cover 64 | 65 | @abstractmethod 66 | def name(self: T, span_name: str) -> T: 67 | pass # pragma: no cover 68 | 69 | @abstractmethod 70 | def new_child(self: T, name: OptStr = None, kind: OptStr = None) -> T: 71 | pass # pragma: no cover 72 | 73 | def __enter__(self: T) -> T: 74 | self.start() 75 | return self 76 | 77 | def __exit__( 78 | self, 79 | exc_type: Optional[Type[Exception]], 80 | exc_value: Optional[Exception], 81 | traceback: Optional[TracebackType], 82 | ) -> None: 83 | self.finish(exception=exc_value) 84 | 85 | 86 | class NoopSpan(SpanAbc): 87 | def __init__( 88 | self, 89 | tracer: "Tracer", 90 | context: TraceContext, 91 | ignored_exceptions: Optional[List[Type[Exception]]] = None, 92 | ) -> None: 93 | self._context = context 94 | self._tracer = tracer 95 | self._ignored_exceptions = ignored_exceptions or [] 96 | 97 | @property 98 | def is_noop(self) -> bool: 99 | return True 100 | 101 | @property 102 | def context(self) -> TraceContext: 103 | return self._context 104 | 105 | @property 106 | def tracer(self) -> "Tracer": 107 | return self._tracer 108 | 109 | def start(self, ts: OptTs = None) -> "NoopSpan": 110 | return self 111 | 112 | def finish( 113 | self, ts: OptTs = None, exception: Optional[Exception] = None 114 | ) -> "NoopSpan": 115 | return self 116 | 117 | def remote_endpoint( 118 | self, 119 | servce_name: OptStr, 120 | *, 121 | ipv4: OptStr = None, 122 | ipv6: OptStr = None, 123 | port: OptInt = None 124 | ) -> "NoopSpan": 125 | return self 126 | 127 | def tag(self, key: str, value: str) -> "NoopSpan": 128 | return self 129 | 130 | def annotate(self, value: Optional[str], ts: OptTs = None) -> "NoopSpan": 131 | return self 132 | 133 | def kind(self, span_kind: str) -> "NoopSpan": 134 | return self 135 | 136 | def name(self, span_name: str) -> "NoopSpan": 137 | return self 138 | 139 | def new_child(self, name: OptStr = None, kind: OptStr = None) -> "NoopSpan": 140 | context = self._tracer._next_context(self.context) 141 | return NoopSpan(self.tracer, context) 142 | 143 | 144 | class Span(SpanAbc): 145 | def __init__( 146 | self, 147 | tracer: "Tracer", 148 | context: TraceContext, 149 | record: Record, 150 | ignored_exceptions: Optional[List[Type[Exception]]] = None, 151 | ) -> None: 152 | self._context = context 153 | self._tracer = tracer 154 | self._record = record 155 | self._ignored_exceptions = ignored_exceptions or [] 156 | 157 | @property 158 | def is_noop(self) -> bool: 159 | return False 160 | 161 | @property 162 | def context(self) -> TraceContext: 163 | return self._context 164 | 165 | @property 166 | def tracer(self) -> "Tracer": 167 | return self._tracer 168 | 169 | def start(self, ts: OptTs = None) -> "Span": 170 | ts = make_timestamp(ts) 171 | self._record.start(ts) 172 | return self 173 | 174 | def finish(self, ts: OptTs = None, exception: Optional[Exception] = None) -> "Span": 175 | if exception is not None: 176 | if not isinstance(exception, tuple(self._ignored_exceptions)): 177 | self.tag(ERROR, str(exception)) 178 | ts = make_timestamp(ts) 179 | self._record.finish(ts) 180 | self._tracer._send(self._record) 181 | return self 182 | 183 | def remote_endpoint( 184 | self, 185 | servce_name: OptStr, 186 | *, 187 | ipv4: OptStr = None, 188 | ipv6: OptStr = None, 189 | port: OptInt = None 190 | ) -> "Span": 191 | endpoint = Endpoint(servce_name, ipv4, ipv6, port) 192 | self._record.remote_endpoint(endpoint) 193 | return self 194 | 195 | def tag(self, key: str, value: str) -> "Span": 196 | self._record.set_tag(key, value) 197 | return self 198 | 199 | def annotate(self, value: Optional[str], ts: OptTs = None) -> "Span": 200 | ts = make_timestamp(ts) 201 | self._record.annotate(value, ts) 202 | return self 203 | 204 | def kind(self, span_kind: str) -> "Span": 205 | self._record.kind(span_kind) 206 | return self 207 | 208 | def name(self, span_name: str) -> "Span": 209 | self._record.name(span_name) 210 | return self 211 | 212 | def new_child(self, name: OptStr = None, kind: OptStr = None) -> "Span": 213 | span = self.tracer.new_child(self.context) 214 | if name is not None: 215 | span.name(name) 216 | if kind is not None: 217 | span.kind(kind) 218 | return span # type: ignore 219 | -------------------------------------------------------------------------------- /aiozipkin/tracer.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | from typing import ( # noqa 3 | TYPE_CHECKING, 4 | Any, 5 | AsyncContextManager, 6 | Awaitable, 7 | Dict, 8 | List, 9 | Optional, 10 | Type, 11 | ) 12 | 13 | from .context_managers import _ContextManager 14 | from .helpers import Endpoint, TraceContext 15 | from .mypy_types import OptBool, OptLoop 16 | from .record import Record 17 | from .sampler import Sampler, SamplerABC 18 | from .span import NoopSpan, Span, SpanAbc 19 | from .transport import StubTransport, Transport, TransportABC 20 | from .utils import generate_random_64bit_string, generate_random_128bit_string 21 | 22 | 23 | if TYPE_CHECKING: 24 | 25 | class _Base(AsyncContextManager["Tracer"]): 26 | pass 27 | 28 | else: 29 | 30 | class _Base(AsyncContextManager): 31 | pass 32 | 33 | 34 | class Tracer(_Base): 35 | def __init__( 36 | self, 37 | transport: TransportABC, 38 | sampler: SamplerABC, 39 | local_endpoint: Endpoint, 40 | ignored_exceptions: Optional[List[Type[Exception]]] = None, 41 | ) -> None: 42 | super().__init__() 43 | self._records: Dict[TraceContext, Record] = {} 44 | self._transport = transport 45 | self._sampler = sampler 46 | self._local_endpoint = local_endpoint 47 | self._ignored_exceptions = ignored_exceptions or [] 48 | 49 | def new_trace(self, sampled: OptBool = None, debug: bool = False) -> SpanAbc: 50 | context = self._next_context(None, sampled=sampled, debug=debug) 51 | return self.to_span(context) 52 | 53 | def join_span(self, context: TraceContext) -> SpanAbc: 54 | new_context = context 55 | if context.sampled is None: 56 | sampled = self._sampler.is_sampled(context.trace_id) 57 | new_context = new_context._replace(sampled=sampled) 58 | else: 59 | new_context = new_context._replace(shared=True) 60 | return self.to_span(new_context) 61 | 62 | def new_child(self, context: TraceContext) -> SpanAbc: 63 | new_context = self._next_context(context) 64 | if not context.sampled: 65 | return NoopSpan(self, new_context, self._ignored_exceptions) 66 | return self.to_span(new_context) 67 | 68 | def to_span(self, context: TraceContext) -> SpanAbc: 69 | if not context.sampled: 70 | return NoopSpan(self, context, self._ignored_exceptions) 71 | 72 | record = Record(context, self._local_endpoint) 73 | self._records[context] = record 74 | return Span(self, context, record, self._ignored_exceptions) 75 | 76 | def _send(self, record: Record) -> None: 77 | self._records.pop(record.context, None) 78 | self._transport.send(record) 79 | 80 | def _next_context( 81 | self, 82 | context: Optional[TraceContext] = None, 83 | sampled: OptBool = None, 84 | debug: bool = False, 85 | ) -> TraceContext: 86 | span_id = generate_random_64bit_string() 87 | if context is not None: 88 | new_context = context._replace( 89 | span_id=span_id, parent_id=context.span_id, shared=False 90 | ) 91 | return new_context 92 | 93 | trace_id = generate_random_128bit_string() 94 | if sampled is None: 95 | sampled = self._sampler.is_sampled(trace_id) 96 | 97 | new_context = TraceContext( 98 | trace_id=trace_id, 99 | parent_id=None, 100 | span_id=span_id, 101 | sampled=sampled, 102 | debug=debug, 103 | shared=False, 104 | ) 105 | return new_context 106 | 107 | async def close(self) -> None: 108 | await self._transport.close() 109 | 110 | async def __aenter__(self) -> "Tracer": 111 | return self 112 | 113 | async def __aexit__(self, *args: Any) -> None: 114 | await self.close() 115 | 116 | 117 | def create( 118 | zipkin_address: str, 119 | local_endpoint: Endpoint, 120 | *, 121 | sample_rate: float = 0.01, 122 | send_interval: float = 5, 123 | loop: OptLoop = None, 124 | ignored_exceptions: Optional[List[Type[Exception]]] = None 125 | ) -> _ContextManager[Tracer]: 126 | if loop is not None: 127 | warnings.warn( 128 | "loop parameter is deprecated and ignored", DeprecationWarning, stacklevel=2 129 | ) 130 | 131 | async def build_tracer() -> Tracer: 132 | sampler = Sampler(sample_rate=sample_rate) 133 | transport = Transport(zipkin_address, send_interval=send_interval) 134 | return Tracer(transport, sampler, local_endpoint, ignored_exceptions) 135 | 136 | result = _ContextManager(build_tracer()) 137 | return result 138 | 139 | 140 | def create_custom( 141 | local_endpoint: Endpoint, 142 | transport: Optional[TransportABC] = None, 143 | sampler: Optional[SamplerABC] = None, 144 | ignored_exceptions: Optional[List[Type[Exception]]] = None, 145 | ) -> _ContextManager[Tracer]: 146 | t = transport or StubTransport() 147 | sample_rate = 1 # sample everything 148 | s = sampler or Sampler(sample_rate=sample_rate) 149 | 150 | async def build_tracer() -> Tracer: 151 | return Tracer(t, s, local_endpoint, ignored_exceptions) 152 | 153 | result = _ContextManager(build_tracer()) 154 | return result 155 | -------------------------------------------------------------------------------- /aiozipkin/transport.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import asyncio 3 | import warnings 4 | from collections import deque 5 | from typing import Any, Awaitable, Callable, Deque, Dict, List, Optional, Tuple 6 | 7 | import aiohttp 8 | from aiohttp.client_exceptions import ClientError 9 | from yarl import URL 10 | 11 | from .log import logger 12 | from .mypy_types import OptLoop 13 | from .record import Record 14 | 15 | 16 | DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=5 * 60) 17 | BATCHES_MAX_COUNT = 10**4 18 | 19 | DataList = List[Dict[str, Any]] 20 | SndBatches = Deque[Tuple[int, DataList]] 21 | SendDataCoro = Callable[[DataList], Awaitable[bool]] 22 | 23 | 24 | class TransportABC(abc.ABC): 25 | @abc.abstractmethod 26 | def send(self, record: Record) -> None: # pragma: no cover 27 | """Sends data to zipkin collector.""" 28 | pass 29 | 30 | @abc.abstractmethod 31 | async def close(self) -> None: # pragma: no cover 32 | """Performs additional cleanup actions if required.""" 33 | pass 34 | 35 | 36 | class StubTransport(TransportABC): 37 | """Dummy transport, which logs spans to a limited queue.""" 38 | 39 | def __init__(self, queue_length: int = 100) -> None: 40 | logger.info("Zipkin address was not provided, using stub transport") 41 | self.records: Deque[Record] = deque(maxlen=queue_length) 42 | 43 | def send(self, record: Record) -> None: 44 | self.records.append(record) 45 | 46 | async def close(self) -> None: 47 | pass 48 | 49 | 50 | class BatchManager: 51 | def __init__( 52 | self, 53 | max_size: int, 54 | send_interval: float, 55 | attempt_count: int, 56 | send_data: SendDataCoro, 57 | ) -> None: 58 | loop = asyncio.get_event_loop() 59 | self._max_size = max_size 60 | self._send_interval = send_interval 61 | self._send_data = send_data 62 | self._attempt_count = attempt_count 63 | self._max = BATCHES_MAX_COUNT 64 | self._sending_batches: SndBatches = deque([], maxlen=self._max) 65 | self._active_batch: Optional[DataList] = None 66 | self._ender = loop.create_future() 67 | self._timer: Optional[asyncio.Future[Any]] = None 68 | self._sender_task = asyncio.ensure_future(self._sender_loop()) 69 | 70 | def add(self, data: Dict[str, Any]) -> None: 71 | if self._active_batch is None: 72 | self._active_batch = [] 73 | self._active_batch.append(data) 74 | if len(self._active_batch) >= self._max_size: 75 | self._sending_batches.append((0, self._active_batch)) 76 | self._active_batch = None 77 | if self._timer is not None and not self._timer.done(): 78 | self._timer.cancel() 79 | 80 | async def stop(self) -> None: 81 | self._ender.set_result(None) 82 | 83 | await self._sender_task 84 | await self._send() 85 | 86 | if self._timer is not None: 87 | self._timer.cancel() 88 | try: 89 | await self._timer 90 | except asyncio.CancelledError: 91 | pass 92 | 93 | async def _sender_loop(self) -> None: 94 | while not self._ender.done(): 95 | await self._wait() 96 | await self._send() 97 | 98 | async def _send(self) -> None: 99 | if self._active_batch is not None: 100 | self._sending_batches.append((0, self._active_batch)) 101 | self._active_batch = None 102 | 103 | batches = self._sending_batches.copy() 104 | self._sending_batches = deque([], maxlen=self._max) 105 | for attempt, batch in batches: 106 | if not await self._send_data(batch): 107 | attempt += 1 108 | if attempt < self._attempt_count: 109 | self._sending_batches.append((attempt, batch)) 110 | 111 | async def _wait(self) -> None: 112 | self._timer = asyncio.ensure_future(asyncio.sleep(self._send_interval)) 113 | 114 | await asyncio.wait( 115 | [self._timer, self._ender], 116 | return_when=asyncio.FIRST_COMPLETED, 117 | ) 118 | 119 | 120 | class Transport(TransportABC): 121 | def __init__( 122 | self, 123 | address: str, 124 | send_interval: float = 5, 125 | loop: OptLoop = None, 126 | *, 127 | send_max_size: int = 100, 128 | send_attempt_count: int = 3, 129 | send_timeout: Optional[aiohttp.ClientTimeout] = None 130 | ) -> None: 131 | if loop is not None: 132 | warnings.warn( 133 | "loop parameter is deprecated and ignored", 134 | DeprecationWarning, 135 | stacklevel=2, 136 | ) 137 | self._address = URL(address) 138 | self._queue: DataList = [] 139 | self._closing = False 140 | self._send_interval = send_interval 141 | if send_timeout is None: 142 | send_timeout = DEFAULT_TIMEOUT 143 | self._session = aiohttp.ClientSession( 144 | timeout=send_timeout, 145 | headers={"Content-Type": "application/json"}, 146 | ) 147 | self._batch_manager = BatchManager( 148 | send_max_size, 149 | send_interval, 150 | send_attempt_count, 151 | self._send_data, 152 | ) 153 | 154 | def send(self, record: Record) -> None: 155 | data = record.asdict() 156 | self._batch_manager.add(data) 157 | 158 | async def _send_data(self, data: DataList) -> bool: 159 | try: 160 | 161 | async with self._session.post(self._address, json=data) as resp: 162 | body = await resp.text() 163 | if resp.status >= 300: 164 | msg = "zipkin responded with code: {} and body: {}".format( 165 | resp.status, body 166 | ) 167 | raise RuntimeError(msg) 168 | 169 | except (asyncio.TimeoutError, ClientError): 170 | return False 171 | except Exception as exc: # pylint: disable=broad-except 172 | # that code should never fail and break application 173 | logger.error("Can not send spans to zipkin", exc_info=exc) 174 | return True 175 | 176 | async def close(self) -> None: 177 | if self._closing: 178 | return 179 | 180 | self._closing = True 181 | await self._batch_manager.stop() 182 | await self._session.close() 183 | -------------------------------------------------------------------------------- /aiozipkin/utils.py: -------------------------------------------------------------------------------- 1 | import random 2 | import struct 3 | import time 4 | 5 | 6 | # https://github.com/Yelp/py_zipkin/blob/ 7 | # 61f8aa3412f6c1b4e1218ed34cb117e97cc9a6cc/py_zipkin/util.py#L22-L75 8 | def generate_random_64bit_string() -> str: 9 | """Returns a 64 bit UTF-8 encoded string. In the interests of simplicity, 10 | this is always cast to a `str` instead of (in py2 land) a unicode string. 11 | Certain clients (I'm looking at you, Twisted) don't enjoy unicode headers. 12 | 13 | :returns: random 16-character string 14 | """ 15 | return f"{random.getrandbits(64):016x}" 16 | 17 | 18 | # https://github.com/Yelp/py_zipkin/blob/ 19 | # 61f8aa3412f6c1b4e1218ed34cb117e97cc9a6cc/py_zipkin/util.py#L32 20 | def generate_random_128bit_string() -> str: 21 | """Returns a 128 bit UTF-8 encoded string. Follows the same conventions 22 | as generate_random_64bit_string(). 23 | 24 | The upper 32 bits are the current time in epoch seconds, and the 25 | lower 96 bits are random. This allows for AWS X-Ray `interop 26 | `_ 27 | 28 | :returns: 32-character hex string 29 | """ 30 | t = int(time.time()) 31 | lower_96 = random.getrandbits(96) 32 | return f"{(t << 96) | lower_96:032x}" 33 | 34 | 35 | def unsigned_hex_to_signed_int(hex_string: str) -> int: 36 | """Converts a 64-bit hex string to a signed int value. 37 | 38 | This is due to the fact that Apache Thrift only has signed values. 39 | 40 | Examples: 41 | '17133d482ba4f605' => 1662740067609015813 42 | 'b6dbb1c2b362bf51' => -5270423489115668655 43 | 44 | :param hex_string: the string representation of a zipkin ID 45 | :returns: signed int representation 46 | """ 47 | v: int = struct.unpack("q", struct.pack("Q", int(hex_string, 16)))[0] 48 | return v 49 | 50 | 51 | def signed_int_to_unsigned_hex(signed_int: int) -> str: 52 | """Converts a signed int value to a 64-bit hex string. 53 | 54 | Examples: 55 | 1662740067609015813 => '17133d482ba4f605' 56 | -5270423489115668655 => 'b6dbb1c2b362bf51' 57 | 58 | :param signed_int: an int to convert 59 | :returns: unsigned hex string 60 | """ 61 | hex_string = hex(struct.unpack("Q", struct.pack("q", signed_int))[0])[2:] 62 | if hex_string.endswith("L"): 63 | return hex_string[:-1] 64 | return hex_string 65 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = aiozipkin 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/aiohttp-icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiozipkin/978e7cde37a838340ad2c4aae1b6a72735fa8414/docs/_static/aiohttp-icon-128x128.png -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API 2 | === 3 | 4 | Core API Reference 5 | ------------------ 6 | 7 | .. module:: aiozipkin 8 | .. currentmodule:: aiozipkin 9 | 10 | 11 | .. data:: CLIENT 12 | 13 | Constant indicates that span has been created on client side. 14 | 15 | .. data:: SERVER 16 | 17 | Constant indicates that span has been created on server side. 18 | 19 | .. data:: PRODUCER 20 | 21 | Constant indicates that span has been created by message producer. 22 | 23 | .. data:: CONSUMER 24 | 25 | Constant indicates that span has been created by message consumer. 26 | 27 | .. function:: make_context(headers: Dict[str, str]) 28 | 29 | Creates tracing context object from from headers mapping if possible, 30 | otherwise returns `None`. 31 | 32 | :param dict headers: hostname to serve monitor telnet server 33 | :returns: TraceContext object or None 34 | 35 | .. cofunction:: create(zipkin_address, local_endpoint, sample_rate, send_interval, loop, ignored_exceptions) 36 | 37 | Creates Tracer object 38 | 39 | :param Endpoint zipkin_address: information related to service address \ 40 | and name, where current zipkin tracer is installed 41 | :param Endpoint local_endpoint: hostname to serve monitor telnet server 42 | :param float sample_rate: hostname to serve monitor telnet server 43 | :param float send_inteval: hostname to serve monitor telnet server 44 | :param asyncio.EventLoop loop: hostname to serve monitor telnet server 45 | :param Optional[List[Type[Exception]]]: ignored_exceptions list of exceptions \ 46 | which will not be labeled as error 47 | :returns: Tracer 48 | 49 | .. cofunction:: create_custom(transport, sampler, local_endpoint, ignored_exceptions) 50 | 51 | Creates Tracer object with a custom Transport and Sampler implementation. 52 | 53 | :param TransportABC transport: custom transport implementation 54 | :param SamplerABC sampler: custom sampler implementation 55 | :param Endpoint local_endpoint: hostname to serve monitor telnet server 56 | :param Optional[List[Type[Exception]]]: ignored_exceptions list of exceptions \ 57 | which will not be labeled as error 58 | :returns: Tracer 59 | 60 | .. class:: Endpoint(serviceName: str, ipv4=None, ipv6=None, port=None) 61 | 62 | This this simple data only class, just holder for service related 63 | information: 64 | 65 | .. attribute:: serviceName 66 | .. attribute:: ipv4 67 | .. attribute:: ipv6 68 | .. attribute:: port 69 | 70 | .. class:: TraceContext(trace_id, parent_id, span_id, sampled, debug, shared) 71 | 72 | Immutable class with trace related data that travels across 73 | process boundaries.: 74 | 75 | :param str trace_id: hostname to serve monitor telnet server 76 | :param Optional[str] parent_id: hostname to serve monitor telnet server 77 | :param str span_id: hostname to serve monitor telnet server 78 | :param str sampled: hostname to serve monitor telnet server 79 | :param str debug: hostname to serve monitor telnet server 80 | :param float shared: hostname to serve monitor telnet server 81 | 82 | .. method:: make_headers() 83 | 84 | :rtype dict: hostname to serve monitor telnet server 85 | 86 | .. class:: Sampler(trace_id, parent_id, span_id, sampled, debug, shared) 87 | 88 | TODO: add 89 | 90 | :param float sample_rate: XXX 91 | :param Optional[int] seed: seed value for random number generator 92 | 93 | .. method:: is_sampled(trace_id) 94 | 95 | XXX 96 | 97 | :rtype bool: hostname to serve monitor telnet server 98 | 99 | 100 | Aiohttp integration API 101 | ----------------------- 102 | 103 | API for integration with :mod:`aiohttp.web`, just calling `setup` is enough for 104 | zipkin to start tracking requests. On high level attached plugin registers 105 | middleware that starts span on beginning of request and closes it on finish, 106 | saving important metadata, like route, status code etc. 107 | 108 | 109 | .. data:: APP_AIOZIPKIN_KEY 110 | 111 | Key, for aiohttp application, where aiozipkin related data is saved. In case 112 | for some reason you want to use 2 aiozipkin instances or change default 113 | name, this parameter should not be used. 114 | 115 | .. data:: REQUEST_AIOZIPKIN_KEY 116 | 117 | Key, for aiohttp request, where aiozipkin span related to current request is 118 | located. 119 | 120 | .. function:: setup(app, tracer, tracer_key=APP_AIOZIPKIN_KEY, request_key=APP_AIOZIPKIN_KEY) 121 | 122 | Sets required parameters in aiohttp applications for aiozipkin. 123 | 124 | Tracer added into application context and cleaned after application 125 | shutdown. You can provide custom tracer_key, if default name is not 126 | suitable. 127 | 128 | :param aiohttp.web.Application app: application for tracer to attach 129 | :param Tracer tracer: aiozipkin tracer 130 | :param List skip_routes: list of routes not to be traced 131 | :param str tracer_key: key for aiozipkin state in aiohttp Application 132 | :param str request_key: key for Span in request object 133 | :returns: :class:`aiohttp.web.Application` 134 | 135 | .. function:: get_tracer(app, tracer_key=APP_AIOZIPKIN_KEY) 136 | 137 | Sets required parameters in aiohttp applications for aiozipkin. 138 | 139 | By default tracer has APP_AIOZIPKIN_KEY in aiohttp application context, 140 | you can provide own key, if for some reason default one is not suitable. 141 | 142 | :param aiottp.web.Application app: application for tracer to attach 143 | :param str tracer_key: key where tracer stored in app 144 | 145 | .. function:: request_span(request, request_key=REQUEST_AIOZIPKIN_KEY) 146 | 147 | Return span created by middleware from request context, you can use it 148 | as parent on next child span. 149 | 150 | :param aiottp.web.Request app: application for tracer to attach 151 | :param str request_key: key where span stored in request 152 | 153 | .. function:: make_trace_config(tracer) 154 | 155 | Creates configuration compatible with aiohttp client. It attaches to 156 | relevant hooks and annotates timing. 157 | 158 | :param Tracer tracer: to install in aiohttp tracer config 159 | :returns: :class:`aiohttp.TraceConfig` 160 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # 3 | # aiozipkin documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Mar 5 12:35:35 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import codecs 16 | import os 17 | import re 18 | import sys 19 | 20 | 21 | _docs_path = os.path.dirname(__file__) 22 | _version_path = os.path.abspath( 23 | os.path.join(_docs_path, "..", "aiozipkin", "__init__.py") 24 | ) 25 | with codecs.open(_version_path, "r", "latin1") as fp: 26 | try: 27 | _version_info = re.search( 28 | r'^__version__ = "' 29 | r"(?P\d+)" 30 | r"\.(?P\d+)" 31 | r"\.(?P\d+)" 32 | r'(?P.*)?"$', 33 | fp.read(), 34 | re.M, 35 | ).groupdict() 36 | except IndexError: 37 | raise RuntimeError("Unable to determine version.") 38 | 39 | 40 | # If extensions (or modules to document with autodoc) are in another directory, 41 | # add these directories to sys.path here. If the directory is relative to the 42 | # documentation root, use os.path.abspath to make it absolute, like shown here. 43 | # sys.path.insert(0, os.path.abspath('..')) 44 | # sys.path.insert(0, os.path.abspath('.')) 45 | 46 | 47 | # -- General configuration ------------------------------------------------ 48 | 49 | # If your documentation needs a minimal Sphinx version, state it here. 50 | # needs_sphinx = '1.0' 51 | 52 | # Add any Sphinx extension module names here, as strings. They can be 53 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 54 | # ones. 55 | extensions = [ 56 | "sphinx.ext.autodoc", 57 | "sphinx.ext.viewcode", 58 | "sphinx.ext.intersphinx", 59 | "sphinxcontrib.asyncio", 60 | ] 61 | 62 | 63 | try: 64 | import sphinxcontrib.spelling # noqa 65 | 66 | extensions.append("sphinxcontrib.spelling") 67 | except ImportError: 68 | pass 69 | 70 | 71 | intersphinx_mapping = { 72 | "python": ("http://docs.python.org/3", None), 73 | "aiohttp": ("https://aiohttp.readthedocs.io/en/stable/", None), 74 | } 75 | 76 | # Add any paths that contain templates here, relative to this directory. 77 | templates_path = ["_templates"] 78 | 79 | # The suffix of source filenames. 80 | source_suffix = ".rst" 81 | 82 | # The encoding of source files. 83 | # source_encoding = 'utf-8-sig' 84 | 85 | # The master toctree document. 86 | master_doc = "index" 87 | 88 | # General information about the project. 89 | project = "aiozipkin" 90 | copyright = "2017-2018, aiozipkin contributors" 91 | 92 | # The version info for the project you're documenting, acts as replacement for 93 | # |version| and |release|, also used in various other places throughout the 94 | # built documents. 95 | # 96 | # The short X.Y version. 97 | version = "{major}.{minor}".format(**_version_info) 98 | # The full version, including alpha/beta/rc tags. 99 | release = "{major}.{minor}.{patch}-{tag}".format(**_version_info) 100 | 101 | # The language for content autogenerated by Sphinx. Refer to documentation 102 | # for a list of supported languages. 103 | # language = None 104 | 105 | # There are two options for replacing |today|: either, you set today to some 106 | # non-false value, then it is used: 107 | # today = '' 108 | # Else, today_fmt is used as the format for a strftime call. 109 | # today_fmt = '%B %d, %Y' 110 | 111 | # List of patterns, relative to source directory, that match files and 112 | # directories to ignore when looking for source files. 113 | exclude_patterns = ["_build"] 114 | 115 | # The reST default role (used for this markup: `text`) to use for all 116 | # documents. 117 | # default_role = None 118 | 119 | # If true, '()' will be appended to :func: etc. cross-reference text. 120 | # add_function_parentheses = True 121 | 122 | # If true, the current module name will be prepended to all description 123 | # unit titles (such as .. function::). 124 | # add_module_names = True 125 | 126 | # If true, sectionauthor and moduleauthor directives will be shown in the 127 | # output. They are ignored by default. 128 | # show_authors = False 129 | 130 | # The name of the Pygments (syntax highlighting) style to use. 131 | # pygments_style = 'sphinx' 132 | 133 | # The default language to highlight source code in. 134 | highlight_language = "python3" 135 | 136 | # A list of ignored prefixes for module index sorting. 137 | # modindex_common_prefix = [] 138 | 139 | # If true, keep warnings as "system message" paragraphs in the built documents. 140 | # keep_warnings = False 141 | 142 | 143 | # -- Options for HTML output ---------------------------------------------- 144 | 145 | # The theme to use for HTML and HTML Help pages. See the documentation for 146 | # a list of builtin themes. 147 | html_theme = "aiohttp_theme" 148 | 149 | # Theme options are theme-specific and customize the look and feel of a theme 150 | # further. For a list of options available for each theme, see the 151 | # documentation. 152 | html_theme_options = { 153 | "logo": "aiohttp-icon-128x128.png", 154 | "description": "Distributed tracing capabilities from asyncio", 155 | "canonical_url": "http://docs.aiozipkin.org/en/stable/", 156 | "github_user": "aio-libs", 157 | "github_repo": "aiozipkin", 158 | "github_button": True, 159 | "github_type": "star", 160 | "github_banner": True, 161 | "badges": [ 162 | { 163 | "image": "https://secure.travis-ci.org/aio-libs/aiozipkin.svg?branch=master", 164 | "target": "https://travis-ci.org/aio-libs/aiozipkin", 165 | "height": "20", 166 | "alt": "Travis CI status", 167 | }, 168 | { 169 | "image": "https://codecov.io/github/aio-libs/aiozipkin/coverage.svg?branch=master", 170 | "target": "https://codecov.io/github/aio-libs/aiozipkin", 171 | "height": "20", 172 | "alt": "Code coverage status", 173 | }, 174 | { 175 | "image": "https://badge.fury.io/py/aiozipkin.svg", 176 | "target": "https://badge.fury.io/py/aiozipkin", 177 | "height": "20", 178 | "alt": "Latest PyPI package version", 179 | }, 180 | { 181 | "image": "https://badges.gitter.im/Join%20Chat.svg", 182 | "target": "https://gitter.im/aio-libs/Lobby", 183 | "height": "20", 184 | "alt": "Chat on Gitter", 185 | }, 186 | ], 187 | } 188 | 189 | # Add any paths that contain custom themes here, relative to this directory. 190 | # html_theme_path = [alabaster.get_path()] 191 | 192 | # The name for this set of Sphinx documents. If None, it defaults to 193 | # " v documentation". 194 | # html_title = None 195 | 196 | # A shorter title for the navigation bar. Default is the same as html_title. 197 | # html_short_title = None 198 | 199 | # The name of an image file (relative to this directory) to place at the top 200 | # of the sidebar. 201 | # html_logo = 'aiozipkin-icon.svg' 202 | 203 | # The name of an image file (within the static path) to use as favicon of the 204 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 205 | # pixels large. 206 | # html_favicon = 'favicon.ico' 207 | 208 | # Add any paths that contain custom static files (such as style sheets) here, 209 | # relative to this directory. They are copied after the builtin static files, 210 | # so a file named "default.css" will overwrite the builtin "default.css". 211 | html_static_path = ["_static"] 212 | 213 | # Add any extra paths that contain custom files (such as robots.txt or 214 | # .htaccess) here, relative to this directory. These files are copied 215 | # directly to the root of the documentation. 216 | # html_extra_path = [] 217 | 218 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 219 | # using the given strftime format. 220 | # html_last_updated_fmt = '%b %d, %Y' 221 | 222 | # If true, SmartyPants will be used to convert quotes and dashes to 223 | # typographically correct entities. 224 | # html_use_smartypants = True 225 | 226 | # Custom sidebar templates, maps document names to template names. 227 | html_sidebars = { 228 | "**": [ 229 | "about.html", 230 | "navigation.html", 231 | "searchbox.html", 232 | ] 233 | } 234 | 235 | # Additional templates that should be rendered to pages, maps page names to 236 | # template names. 237 | # html_additional_pages = {} 238 | 239 | # If false, no module index is generated. 240 | # html_domain_indices = True 241 | 242 | # If false, no index is generated. 243 | # html_use_index = True 244 | 245 | # If true, the index is split into individual pages for each letter. 246 | # html_split_index = False 247 | 248 | # If true, links to the reST sources are added to the pages. 249 | # html_show_sourcelink = True 250 | 251 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 252 | # html_show_sphinx = True 253 | 254 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 255 | # html_show_copyright = True 256 | 257 | # If true, an OpenSearch description file will be output, and all pages will 258 | # contain a tag referring to it. The value of this option must be the 259 | # base URL from which the finished HTML is served. 260 | # html_use_opensearch = '' 261 | 262 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 263 | # html_file_suffix = None 264 | 265 | # Output file base name for HTML help builder. 266 | htmlhelp_basename = "aiozipkindoc" 267 | 268 | 269 | # -- Options for LaTeX output --------------------------------------------- 270 | 271 | latex_elements = { 272 | # The paper size ('letterpaper' or 'a4paper'). 273 | # 'papersize': 'letterpaper', 274 | # The font size ('10pt', '11pt' or '12pt'). 275 | # 'pointsize': '10pt', 276 | # Additional stuff for the LaTeX preamble. 277 | # 'preamble': '', 278 | } 279 | 280 | # Grouping the document tree into LaTeX files. List of tuples 281 | # (source start file, target name, title, 282 | # author, documentclass [howto, manual, or own class]). 283 | latex_documents = [ 284 | ( 285 | "index", 286 | "aiozipkin.tex", 287 | "aiozipkin Documentation", 288 | "aiozipkin contributors", 289 | "manual", 290 | ), 291 | ] 292 | 293 | # The name of an image file (relative to this directory) to place at the top of 294 | # the title page. 295 | # latex_logo = None 296 | 297 | # For "manual" documents, if this is true, then toplevel headings are parts, 298 | # not chapters. 299 | # latex_use_parts = False 300 | 301 | # If true, show page references after internal links. 302 | # latex_show_pagerefs = False 303 | 304 | # If true, show URL addresses after external links. 305 | # latex_show_urls = False 306 | 307 | # Documents to append as an appendix to all manuals. 308 | # latex_appendices = [] 309 | 310 | # If false, no module index is generated. 311 | # latex_domain_indices = True 312 | 313 | 314 | # -- Options for manual page output --------------------------------------- 315 | 316 | # One entry per manual page. List of tuples 317 | # (source start file, name, description, authors, manual section). 318 | man_pages = [("index", "aiozipkin", "aiozipkin Documentation", ["aiozipkin"], 1)] 319 | 320 | # If true, show URL addresses after external links. 321 | # man_show_urls = False 322 | 323 | 324 | # -- Options for Texinfo output ------------------------------------------- 325 | 326 | # Grouping the document tree into Texinfo files. List of tuples 327 | # (source start file, target name, title, author, 328 | # dir menu entry, description, category) 329 | texinfo_documents = [ 330 | ( 331 | "index", 332 | "aiozipkin", 333 | "aiohttp Documentation", 334 | "Aiozipkin contributors", 335 | "aiozipkin", 336 | "One line description of project.", 337 | "Miscellaneous", 338 | ), 339 | ] 340 | 341 | # Documents to append as an appendix to all manuals. 342 | # texinfo_appendices = [] 343 | 344 | # If false, no module index is generated. 345 | # texinfo_domain_indices = True 346 | 347 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 348 | # texinfo_show_urls = 'footnote' 349 | 350 | # If true, do not generate a @detailmenu in the "Top" node's menu. 351 | # texinfo_no_detailmenu = False 352 | -------------------------------------------------------------------------------- /docs/contributing.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../CONTRIBUTING.rst 2 | -------------------------------------------------------------------------------- /docs/examples.rst: -------------------------------------------------------------------------------- 1 | Examples of aiozipkin usage 2 | =========================== 3 | 4 | Below is a list of examples from `aiozipkin/examples 5 | `_ 6 | 7 | Every example is a correct tiny python program. 8 | 9 | .. _aiozipkin-examples-simple: 10 | 11 | Basic Usage 12 | ----------- 13 | 14 | 15 | .. literalinclude:: ../examples/simple.py 16 | 17 | aiohttp Example 18 | --------------- 19 | 20 | Full featured example with aiohttp application: 21 | 22 | .. literalinclude:: ../examples/aiohttp_example.py 23 | 24 | 25 | Fastapi 26 | ------- 27 | 28 | Fastapi support can be found with the `starlette-zipkin 29 | `_ package. 30 | 31 | 32 | Microservices Demo 33 | ------------------ 34 | There is a larger micro services example, using aiohttp. This demo consists of 35 | five simple services that call each other, as result you can study client 36 | server communication and zipkin integration for large projects. For more 37 | information see: 38 | 39 | ``_ 40 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. aiozipkin documentation master file, created by 2 | sphinx-quickstart on Sun Dec 11 17:08:38 2016. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | aiozipkin's documentation! 7 | =========================== 8 | 9 | 10 | **aiozipkin** is Python 3.6+ module that adds distributed tracing capabilities 11 | for asyncio_ applications with zipkin (http://zipkin.io) server instrumentation. 12 | 13 | zipkin_ is a distributed tracing system. It helps gather timing data needed 14 | to troubleshoot latency problems in microservice architectures. It manages 15 | both the collection and lookup of this data. Zipkin’s design is based on 16 | the Google Dapper paper. 17 | 18 | Applications instrumented with **aiozipkin** report timing data to zipkin_. 19 | The Zipkin UI also presents a Dependency diagram showing how many traced 20 | requests went through each application. If you are troubleshooting latency 21 | problems or errors, you can filter or sort all traces based on the 22 | application, length of trace, annotation, or timestamp. 23 | 24 | 25 | .. image:: https://raw.githubusercontent.com/aio-libs/aiozipkin/master/docs/zipkin_animation2.gif 26 | :alt: zipkin ui animation 27 | 28 | 29 | Features 30 | -------- 31 | * Distributed tracing capabilities to **asyncio** applications. 32 | * Supports zipkin_ ``v2`` protocol. 33 | * Easy to use API. 34 | * Explicit context handling, no thread local variables. 35 | * Can work with jaeger_ and stackdriver_ (google_) through zipkin compatible API. 36 | * Can be integrated with AWS X-Ray by aws_ proxy. 37 | 38 | Contents 39 | -------- 40 | 41 | .. toctree:: 42 | :maxdepth: 2 43 | 44 | tutorial 45 | examples 46 | other 47 | api 48 | contributing 49 | 50 | 51 | Indices and tables 52 | ================== 53 | 54 | * :ref:`genindex` 55 | * :ref:`modindex` 56 | * :ref:`search` 57 | 58 | 59 | .. _PEP492: https://www.python.org/dev/peps/pep-0492/ 60 | .. _Python: https://www.python.org 61 | .. _aiohttp: https://github.com/aio-libs/aiohttp 62 | .. _asyncio: http://docs.python.org/3/library/asyncio.html 63 | .. _uvloop: https://github.com/MagicStack/uvloop 64 | .. _zipkin: http://zipkin.io 65 | .. _jaeger: http://jaeger.readthedocs.io/en/latest/ 66 | .. _stackdriver: https://cloud.google.com/stackdriver/ 67 | .. _google: https://cloud.google.com/trace/docs/zipkin 68 | .. _aws: https://github.com/openzipkin/zipkin-aws 69 | -------------------------------------------------------------------------------- /docs/jaeger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiozipkin/978e7cde37a838340ad2c4aae1b6a72735fa8414/docs/jaeger.png -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | set SPHINXPROJ=aiozipkin 13 | 14 | if "%1" == "" goto help 15 | 16 | %SPHINXBUILD% >NUL 2>NUL 17 | if errorlevel 9009 ( 18 | echo. 19 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 20 | echo.installed, then set the SPHINXBUILD environment variable to point 21 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 22 | echo.may add the Sphinx directory to PATH. 23 | echo. 24 | echo.If you don't have Sphinx installed, grab it from 25 | echo.http://sphinx-doc.org/ 26 | exit /b 1 27 | ) 28 | 29 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 30 | goto end 31 | 32 | :help 33 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 34 | 35 | :end 36 | popd 37 | -------------------------------------------------------------------------------- /docs/other.rst: -------------------------------------------------------------------------------- 1 | Support of other collectors 2 | =========================== 3 | **aiozipkin** can work with any other zipkin_ compatible service, currently we 4 | tested it with jaeger_ and stackdriver_. 5 | 6 | Jaeger support 7 | -------------- 8 | jaeger_ supports zipkin_ span format and as a result it is possible to use *aiozipkin* 9 | with jaeger_ server. You just need to specify *jaeger* server address and it 10 | should work out of the box. No need to run a local zipkin server. 11 | For more information see tests and jaeger_ documentation. 12 | 13 | .. image:: https://raw.githubusercontent.com/aio-libs/aiozipkin/master/docs/jaeger.png 14 | :alt: jaeger ui animation 15 | 16 | 17 | StackDriver support 18 | ------------------- 19 | Google stackdriver_ supports zipkin_ span format as a result it is possible to 20 | use *aiozipkin* with this google_ service. In order to make this work you 21 | need to setup zipkin service locally, that will send traces to the cloud. See 22 | google_ cloud documentation how to setup make zipkin collector: 23 | 24 | .. image:: https://raw.githubusercontent.com/aio-libs/aiozipkin/master/docs/stackdriver.png 25 | :alt: jaeger ui animation 26 | 27 | 28 | .. _zipkin: http://zipkin.io 29 | .. _jaeger: http://jaeger.readthedocs.io/en/latest/ 30 | .. _stackdriver: https://cloud.google.com/stackdriver/ 31 | .. _google: https://cloud.google.com/trace/docs/zipkin 32 | -------------------------------------------------------------------------------- /docs/spelling_wordlist.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | config 3 | fastapi 4 | hostname 5 | jaeger 6 | microservice 7 | microservices 8 | middleware 9 | zipkin 10 | -------------------------------------------------------------------------------- /docs/stackdriver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiozipkin/978e7cde37a838340ad2c4aae1b6a72735fa8414/docs/stackdriver.png -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | -------------------------------------------------------------------------------- /docs/zipkin_animation2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiozipkin/978e7cde37a838340ad2c4aae1b6a72735fa8414/docs/zipkin_animation2.gif -------------------------------------------------------------------------------- /docs/zipkin_glossary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiozipkin/978e7cde37a838340ad2c4aae1b6a72735fa8414/docs/zipkin_glossary.png -------------------------------------------------------------------------------- /docs/zipkin_ui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aio-libs/aiozipkin/978e7cde37a838340ad2c4aae1b6a72735fa8414/docs/zipkin_ui.png -------------------------------------------------------------------------------- /examples/aiohttp_example.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiohttp import web 4 | 5 | import aiozipkin as az 6 | 7 | 8 | async def handle(request: web.Request) -> web.StreamResponse: 9 | tracer = az.get_tracer(request.app) 10 | span = az.request_span(request) 11 | 12 | with tracer.new_child(span.context) as child_span: 13 | child_span.name("mysql:select") 14 | # call to external service like https://python.org 15 | # or database query 16 | await asyncio.sleep(0.01) 17 | 18 | text = """ 19 | 20 | 21 | aiohttp simple example 22 | 23 | 24 |

This page was traced by aiozipkin

25 |

Go to not traced page

26 | 27 | 28 | """ 29 | return web.Response(text=text, content_type="text/html") 30 | 31 | 32 | async def not_traced_handle(request: web.Request) -> web.StreamResponse: 33 | text = """ 34 | 35 | 36 | aiohttp simple example 37 | 38 | 39 |

This page was NOT traced by aiozipkin>

40 |

Go to traced page

41 | 42 | 43 | """ 44 | return web.Response(text=text, content_type="text/html") 45 | 46 | 47 | async def make_app(host: str, port: int) -> web.Application: 48 | app = web.Application() 49 | app.router.add_get("/", handle) 50 | # here we aquire reference to route, so later we can command 51 | # aiozipkin not to trace it 52 | skip_route = app.router.add_get("/status", not_traced_handle) 53 | 54 | endpoint = az.create_endpoint("aiohttp_server", ipv4=host, port=port) 55 | 56 | zipkin_address = "http://127.0.0.1:9411/api/v2/spans" 57 | tracer = await az.create(zipkin_address, endpoint, sample_rate=1.0) 58 | az.setup(app, tracer, skip_routes=[skip_route]) 59 | return app 60 | 61 | 62 | def run() -> None: 63 | host = "127.0.0.1" 64 | port = 9001 65 | loop = asyncio.get_event_loop() 66 | app = loop.run_until_complete(make_app(host, port)) 67 | web.run_app(app, host=host, port=port) 68 | 69 | 70 | if __name__ == "__main__": 71 | run() 72 | -------------------------------------------------------------------------------- /examples/microservices/README.rst: -------------------------------------------------------------------------------- 1 | Microservices Demo 2 | ================== 3 | 4 | Example of microservices project using aiohttp_. There are 5 services, which 5 | calls each other to serve request. You can explore traces and services 6 | interconnection in Zipkin UI. This demo uses new aiohttp features available 7 | only in >= 3.0.0 version and python 3.7 context variables. 8 | 9 | 10 | Installation 11 | ============ 12 | 13 | Clone repository and install required dependencies:: 14 | 15 | $ git clone git@github.com:aio-libs/aiozipkin.git 16 | $ cd aiozipkin 17 | $ pip install -e . 18 | $ pip install -r requirements-dev.txt 19 | $ pip install aiohttp-jinja2 20 | 21 | 22 | Start zipkin server (make sure you have docker installed):: 23 | 24 | $ make zipkin_start 25 | 26 | To stop zipkin server (stop and remove docker container):: 27 | 28 | $ make zipkin_stop 29 | 30 | To start all service execute following command::: 31 | 32 | $ python examples/microservices/runner.py 33 | 34 | Open browser:: 35 | 36 | http://127.0.0.1:9001 37 | 38 | 39 | First service will start chain of http calls to other services, and then will 40 | render page with fetched information. You can investigate that chain and timing 41 | in Zipkin UI. 42 | 43 | Zipkin UI available:: 44 | 45 | http://127.0.0.1:9411/zipkin/ 46 | 47 | 48 | 49 | Requirements 50 | ============ 51 | * aiohttp_ 52 | 53 | .. _Python: https://www.python.org 54 | .. _aiohttp: https://github.com/aio-libs/aiohttp 55 | -------------------------------------------------------------------------------- /examples/microservices/runner.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | import service_a 5 | import service_b 6 | import service_c 7 | import service_d 8 | import service_e 9 | from aiohttp import web 10 | 11 | 12 | async def start_app(service, host, port): 13 | app = await service.make_app() 14 | runner = web.AppRunner(app) 15 | await runner.setup() 16 | site = web.TCPSite(runner, host, port) 17 | await site.start() 18 | return runner 19 | 20 | 21 | def run(): 22 | host = "127.0.0.1" 23 | loop = asyncio.get_event_loop() 24 | services = [service_a, service_b, service_c, service_d, service_e] 25 | runners = [] 26 | for i, service in enumerate(services): 27 | port = 9001 + i 28 | runner = loop.run_until_complete(start_app(service, host, port)) 29 | runners.append(runner) 30 | 31 | print("Open in browser: http://127.0.0.1:9001") 32 | try: 33 | loop.run_forever() 34 | except KeyboardInterrupt: 35 | for runner in runners: 36 | loop.run_until_complete(runner.cleanup()) 37 | 38 | 39 | if __name__ == "__main__": 40 | logging.basicConfig(level=logging.INFO) 41 | run() 42 | -------------------------------------------------------------------------------- /examples/microservices/service_a.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import pathlib 3 | 4 | import aiohttp 5 | import aiohttp_jinja2 6 | import jinja2 7 | from aiohttp import web 8 | 9 | import aiozipkin as az 10 | 11 | 12 | service_b_api = "http://127.0.0.1:9002/api/v1/data" 13 | service_e_api = "http://127.0.0.1:9005/api/v1/data" 14 | host = "127.0.0.1" 15 | port = 9001 16 | zipkin_address = "http://127.0.0.1:9411/api/v2/spans" 17 | zipkin_ui_address = "http://127.0.0.1:9411/zipkin/" 18 | 19 | 20 | async def handler(request): 21 | await asyncio.sleep(0.01) 22 | session = request.app["session"] 23 | 24 | resp = await session.get(service_b_api) 25 | data_b = await resp.json() 26 | 27 | resp = await session.get(service_e_api) 28 | data_e = await resp.json() 29 | 30 | tree = { 31 | "name": "service_a", 32 | "host": host, 33 | "port": port, 34 | "children": [data_b, data_e], 35 | } 36 | ctx = {"zipkin": zipkin_ui_address, "service": tree} 37 | return aiohttp_jinja2.render_template("index.html", request, ctx) 38 | 39 | 40 | async def make_app(): 41 | 42 | app = web.Application() 43 | app.router.add_get("/api/v1/data", handler) 44 | app.router.add_get("/", handler) 45 | 46 | endpoint = az.create_endpoint("service_a", ipv4=host, port=port) 47 | tracer = await az.create(zipkin_address, endpoint, sample_rate=1.0) 48 | 49 | trace_config = az.make_trace_config(tracer) 50 | 51 | session = aiohttp.ClientSession(trace_configs=[trace_config]) 52 | app["session"] = session 53 | 54 | async def close_session(app): 55 | await app["session"].close() 56 | 57 | app.on_cleanup.append(close_session) 58 | 59 | az.setup(app, tracer) 60 | 61 | TEMPLATES_ROOT = pathlib.Path(__file__).parent / "templates" 62 | aiohttp_jinja2.setup(app, loader=jinja2.FileSystemLoader(str(TEMPLATES_ROOT))) 63 | 64 | return app 65 | 66 | 67 | if __name__ == "__main__": 68 | loop = asyncio.get_event_loop() 69 | app = loop.run_until_complete(make_app()) 70 | web.run_app(app, host=host, port=port) 71 | -------------------------------------------------------------------------------- /examples/microservices/service_b.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiohttp 4 | from aiohttp import web 5 | 6 | import aiozipkin as az 7 | 8 | 9 | service_c_api = "http://127.0.0.1:9003/api/v1/data" 10 | service_d_api = "http://127.0.0.1:9004/api/v1/data" 11 | host = "127.0.0.1" 12 | port = 9002 13 | 14 | 15 | async def handler(request): 16 | await asyncio.sleep(0.01) 17 | session = request.app["session"] 18 | 19 | resp = await session.get(service_c_api) 20 | data_c = await resp.json() 21 | 22 | resp = await session.get(service_d_api) 23 | data_d = await resp.json() 24 | 25 | payload = { 26 | "name": "service_b", 27 | "host": host, 28 | "port": port, 29 | "children": [data_c, data_d], 30 | } 31 | return web.json_response(payload) 32 | 33 | 34 | async def make_app(): 35 | app = web.Application() 36 | app.router.add_get("/api/v1/data", handler) 37 | 38 | zipkin_address = "http://127.0.0.1:9411/api/v2/spans" 39 | endpoint = az.create_endpoint("service_b", ipv4=host, port=port) 40 | tracer = await az.create(zipkin_address, endpoint, sample_rate=1.0) 41 | az.setup(app, tracer) 42 | 43 | trace_config = az.make_trace_config(tracer) 44 | 45 | session = aiohttp.ClientSession(trace_configs=[trace_config]) 46 | app["session"] = session 47 | 48 | async def close_session(app): 49 | await app["session"].close() 50 | 51 | app.on_cleanup.append(close_session) 52 | return app 53 | 54 | 55 | if __name__ == "__main__": 56 | loop = asyncio.get_event_loop() 57 | app = loop.run_until_complete(make_app()) 58 | web.run_app(app, host=host, port=port) 59 | -------------------------------------------------------------------------------- /examples/microservices/service_c.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiohttp import web 4 | 5 | import aiozipkin as az 6 | 7 | 8 | host = "127.0.0.1" 9 | port = 9003 10 | 11 | 12 | async def handler(request): 13 | await asyncio.sleep(0.01) 14 | payload = {"name": "service_c", "host": host, "port": port, "children": []} 15 | return web.json_response(payload) 16 | 17 | 18 | async def make_app(): 19 | app = web.Application() 20 | app.router.add_get("/api/v1/data", handler) 21 | 22 | zipkin_address = "http://127.0.0.1:9411/api/v2/spans" 23 | endpoint = az.create_endpoint("service_c", ipv4=host, port=port) 24 | tracer = await az.create(zipkin_address, endpoint, sample_rate=1.0) 25 | az.setup(app, tracer) 26 | return app 27 | 28 | 29 | if __name__ == "__main__": 30 | loop = asyncio.get_event_loop() 31 | app = loop.run_until_complete(make_app()) 32 | web.run_app(app, host=host, port=port) 33 | -------------------------------------------------------------------------------- /examples/microservices/service_d.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiohttp import web 4 | 5 | import aiozipkin as az 6 | 7 | 8 | host = "127.0.0.1" 9 | port = 9004 10 | 11 | 12 | async def handler(request): 13 | await asyncio.sleep(0.01) 14 | payload = {"name": "service_d", "host": host, "port": port, "children": []} 15 | return web.json_response(payload) 16 | 17 | 18 | async def make_app(): 19 | app = web.Application() 20 | app.router.add_get("/api/v1/data", handler) 21 | 22 | zipkin_address = "http://127.0.0.1:9411/api/v2/spans" 23 | endpoint = az.create_endpoint("service_d", ipv4=host, port=port) 24 | tracer = await az.create(zipkin_address, endpoint, sample_rate=1.0) 25 | az.setup(app, tracer) 26 | return app 27 | 28 | 29 | if __name__ == "__main__": 30 | loop = asyncio.get_event_loop() 31 | app = loop.run_until_complete(make_app()) 32 | web.run_app(app, host=host, port=port) 33 | -------------------------------------------------------------------------------- /examples/microservices/service_e.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from aiohttp import web 4 | 5 | import aiozipkin as az 6 | 7 | 8 | host = "127.0.0.1" 9 | port = 9005 10 | 11 | 12 | async def handler(request): 13 | await asyncio.sleep(0.01) 14 | payload = {"name": "service_e", "host": host, "port": port, "children": []} 15 | return web.json_response(payload) 16 | 17 | 18 | async def make_app(): 19 | app = web.Application() 20 | app.router.add_get("/api/v1/data", handler) 21 | 22 | zipkin_address = "http://127.0.0.1:9411/api/v2/spans" 23 | endpoint = az.create_endpoint("service_e", ipv4=host, port=port) 24 | tracer = await az.create(zipkin_address, endpoint, sample_rate=1.0) 25 | az.setup(app, tracer) 26 | return app 27 | 28 | 29 | if __name__ == "__main__": 30 | loop = asyncio.get_event_loop() 31 | app = loop.run_until_complete(make_app()) 32 | web.run_app(app, host=host, port=port) 33 | -------------------------------------------------------------------------------- /examples/microservices/templates/index.html: -------------------------------------------------------------------------------- 1 | {% macro render_service(service) -%} 2 |
3 |

4 | {{service.name}} 5 |

6 |
    7 |
  • host: {{service.host}}
  • 8 |
  • port: {{service.port}}
  • 9 |
10 |
11 | {% for s in service.children %} 12 | {{ render_service(s) }} 13 | {% endfor %} 14 |
15 |
16 | {%- endmacro %} 17 | 18 | 19 | 20 | 21 | aiozipkin microservices demo 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 |
31 |
32 |

aiozipkin microservices demo

33 |

34 | There is a larger micro services example, using aiohttp. 35 | This demo consists of five simple services that call each other, as a result you can study client 36 | server communication and zipkin integration for large projects. Each 37 | box element of this page is rendered with help of different service. 38 |

39 |

Zipkin UI {{zipkin}}

40 | {{ render_service(service) }} 41 | 42 |
43 |
44 |
45 | 46 | https://github.com/aio-libs/aiozipkin 47 | 48 |
49 |
50 | 51 | 52 | -------------------------------------------------------------------------------- /examples/minimal.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiozipkin as az 4 | 5 | 6 | async def run() -> None: 7 | # setup zipkin client 8 | zipkin_address = "http://127.0.0.1:9411/api/v2/spans" 9 | # address and name of current machine for better trace information 10 | endpoint = az.create_endpoint("minimal_example", ipv4="127.0.0.1") 11 | 12 | # creates tracer object that tracer all calls if you want sample 13 | # only 50% just set sample_rate=0.5 14 | async with az.create(zipkin_address, endpoint, sample_rate=1.0) as tracer: 15 | # create and setup new trace 16 | with tracer.new_trace() as span: 17 | # here we just add name to the span for better search in UI 18 | span.name("root::span") 19 | # imitate long SQL query 20 | await asyncio.sleep(0.1) 21 | 22 | print("Done, check zipkin UI") 23 | 24 | 25 | if __name__ == "__main__": 26 | loop = asyncio.get_event_loop() 27 | loop.run_until_complete(run()) 28 | -------------------------------------------------------------------------------- /examples/queue/backend.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiojobs.aiohttp 4 | from aiohttp import web 5 | 6 | import aiozipkin as az 7 | 8 | 9 | async def consume_message(message, tracer): 10 | await asyncio.sleep(0.1) 11 | headers = message.get("headers", None) 12 | context = az.make_context(headers) 13 | 14 | with tracer.new_child(context) as span_consumer: 15 | span_consumer.name("consumer event") 16 | span_consumer.remote_endpoint("broker", ipv4="127.0.0.1", port=9011) 17 | span_consumer.kind(az.CONSUMER) 18 | 19 | with tracer.new_child(span_consumer.context) as span_worker: 20 | span_worker.name("process event") 21 | await asyncio.sleep(0.1) 22 | 23 | 24 | async def handler(request): 25 | message = await request.json() 26 | tracer = az.get_tracer(request.app) 27 | for _ in range(5): 28 | asyncio.ensure_future( 29 | aiojobs.aiohttp.spawn(request, consume_message(message, tracer)) 30 | ) 31 | 32 | return web.Response(text="ok") 33 | 34 | 35 | async def make_app(host, port): 36 | app = web.Application() 37 | app.router.add_post("/consume", handler) 38 | aiojobs.aiohttp.setup(app) 39 | 40 | zipkin_address = "http://127.0.0.1:9411/api/v2/spans" 41 | endpoint = az.create_endpoint("backend_broker", ipv4=host, port=port) 42 | tracer = await az.create(zipkin_address, endpoint, sample_rate=1.0) 43 | az.setup(app, tracer) 44 | return app 45 | 46 | 47 | if __name__ == "__main__": 48 | host = "127.0.0.1" 49 | port = 9011 50 | loop = asyncio.get_event_loop() 51 | app = loop.run_until_complete(make_app(host, port)) 52 | web.run_app(app, host=host, port=port) 53 | -------------------------------------------------------------------------------- /examples/queue/frontend.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiohttp 4 | from aiohttp import web 5 | 6 | import aiozipkin as az 7 | 8 | 9 | page = """ 10 | 11 | 12 | aiohttp producer consumer demo 13 | 14 | 15 |

Your click event send to consumer

16 | 17 | 18 | """ 19 | 20 | 21 | backend_service = "http://127.0.0.1:9011/consume" 22 | 23 | 24 | async def index(request): 25 | span = az.request_span(request) 26 | tracer = az.get_tracer(request.app) 27 | session = request.app["session"] 28 | 29 | with tracer.new_child(span.context) as span_producer: 30 | span_producer.kind(az.PRODUCER) 31 | span_producer.name("produce event click") 32 | span_producer.remote_endpoint("broker", ipv4="127.0.0.1", port=9011) 33 | 34 | headers = span_producer.context.make_headers() 35 | message = {"payload": "click", "headers": headers} 36 | resp = await session.post(backend_service, json=message) 37 | resp = await resp.text() 38 | assert resp == "ok" 39 | 40 | await asyncio.sleep(0.01) 41 | return web.Response(text=page, content_type="text/html") 42 | 43 | 44 | async def make_app(host, port): 45 | app = web.Application() 46 | app.router.add_get("/", index) 47 | 48 | session = aiohttp.ClientSession() 49 | app["session"] = session 50 | 51 | zipkin_address = "http://127.0.0.1:9411/api/v2/spans" 52 | endpoint = az.create_endpoint("frontend", ipv4=host, port=port) 53 | tracer = await az.create(zipkin_address, endpoint, sample_rate=1.0) 54 | az.setup(app, tracer) 55 | return app 56 | 57 | 58 | if __name__ == "__main__": 59 | host = "127.0.0.1" 60 | port = 9010 61 | loop = asyncio.get_event_loop() 62 | app = loop.run_until_complete(make_app(host, port)) 63 | web.run_app(app, host=host, port=port) 64 | -------------------------------------------------------------------------------- /examples/queue/runner.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import backend 4 | import frontend 5 | 6 | 7 | def run(): 8 | loop = asyncio.get_event_loop() 9 | loop_run = loop.run_until_complete 10 | host = "127.0.0.1" 11 | fe_port = 9010 12 | be_port = 9011 13 | 14 | print("http://127.0.0.1:9010") 15 | 16 | fe_app = loop_run(frontend.make_app(host, fe_port)) 17 | be_app = loop_run(backend.make_app(host, be_port)) 18 | 19 | fe_handler = fe_app.make_handler() 20 | be_handler = be_app.make_handler() 21 | handlers = [fe_handler, be_handler] 22 | 23 | loop_run(fe_app.startup()) 24 | loop_run(be_app.startup()) 25 | loop_run(loop.create_server(fe_handler, host, fe_port)) 26 | loop_run(loop.create_server(be_handler, host, be_port)) 27 | 28 | try: 29 | loop.run_forever() 30 | except KeyboardInterrupt: 31 | for handler in handlers: 32 | loop_run(handler.finish_connections()) 33 | 34 | 35 | if __name__ == "__main__": 36 | run() 37 | -------------------------------------------------------------------------------- /examples/simple.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiozipkin as az 4 | 5 | 6 | async def run() -> None: 7 | # setup zipkin client 8 | zipkin_address = "http://127.0.0.1:9411/api/v2/spans" 9 | endpoint = az.create_endpoint("simple_service", ipv4="127.0.0.1", port=8080) 10 | 11 | # creates tracer object that traces all calls, if you want sample 12 | # only 50% just set sample_rate=0.5 13 | tracer = await az.create(zipkin_address, endpoint, sample_rate=1.0) 14 | 15 | # create and setup new trace 16 | with tracer.new_trace(sampled=True) as span: 17 | span.name("root_span") 18 | span.tag("span_type", "root") 19 | span.kind(az.CLIENT) 20 | span.annotate("SELECT * FROM") 21 | # imitate long SQL query 22 | await asyncio.sleep(0.1) 23 | span.annotate("start end sql") 24 | 25 | # create child span 26 | with tracer.new_child(span.context) as nested_span: 27 | nested_span.name("nested_span_1") 28 | nested_span.kind(az.CLIENT) 29 | nested_span.tag("span_type", "inner1") 30 | nested_span.remote_endpoint("remote_service_1") 31 | await asyncio.sleep(0.01) 32 | 33 | # create other child span 34 | with tracer.new_child(span.context) as nested_span: 35 | nested_span.name("nested_span_2") 36 | nested_span.kind(az.CLIENT) 37 | nested_span.remote_endpoint("remote_service_2") 38 | nested_span.tag("span_type", "inner2") 39 | await asyncio.sleep(0.01) 40 | 41 | await tracer.close() 42 | print("-" * 100) 43 | print("Check zipkin UI for produced traces: http://localhost:9411/zipkin") 44 | 45 | 46 | if __name__ == "__main__": 47 | loop = asyncio.get_event_loop() 48 | loop.run_until_complete(run()) 49 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | package = "aiozipkin" 3 | filename = "CHANGES.rst" 4 | directory = "CHANGES/" 5 | title_format = "{version} ({project_date})" 6 | template = "CHANGES/.TEMPLATE.rst" 7 | issue_format = "`#{issue} `_" 8 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | aiodocker==0.21.0 3 | aiohttp==3.8.1 4 | async-generator==1.10 5 | attrs==21.4.0 6 | bandit==1.7.4 7 | flake8==4.0.1 8 | flake8-bugbear==22.6.22 9 | mypy==0.961 10 | pre-commit==2.19.0 11 | pyroma==4.0 12 | pytest==7.1.2 13 | pytest-aiohttp==1.0.4 14 | pytest-asyncio==0.18.3 15 | pytest-cov==3.0.0 16 | towncrier==21.9.0 17 | twine==4.0.1 18 | -------------------------------------------------------------------------------- /requirements-doc.txt: -------------------------------------------------------------------------------- 1 | aiohttp-theme==0.1.6 2 | setuptools>=38.0.0 3 | sphinx==5.0.2 4 | sphinxcontrib-asyncio==0.3.0 5 | sphinxcontrib-spelling==7.6.0; platform_system!="Windows" 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .git,.env,__pycache__,.eggs 3 | max-line-length = 88 4 | ignore = N801,N802,N803,E252,W503,E133,E203,F541,B101 5 | 6 | [isort] 7 | line_length=88 8 | include_trailing_comma=True 9 | multi_line_output=3 10 | force_grid_wrap=0 11 | combine_as_imports=True 12 | lines_after_imports=2 13 | 14 | [tool:pytest] 15 | filterwarnings=error 16 | testpaths = tests/ 17 | asyncio_mode = auto 18 | 19 | [mypy-async_generator] 20 | ignore_missing_imports = True 21 | 22 | [mypy-pytest] 23 | ignore_missing_imports = True 24 | 25 | [mypy-setuptools] 26 | ignore_missing_imports = True 27 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | 5 | from setuptools import find_packages, setup # type: ignore 6 | 7 | 8 | def read(f: str) -> str: 9 | return open(os.path.join(os.path.dirname(__file__), f)).read().strip() 10 | 11 | 12 | install_requires = ["aiohttp>=3.7.2"] 13 | 14 | 15 | def read_version() -> str: 16 | regexp = re.compile(r'^__version__\W*=\W*"([\d.abrc]+)"') 17 | init_py = os.path.join(os.path.dirname(__file__), "aiozipkin", "__init__.py") 18 | with open(init_py) as f: 19 | for line in f: 20 | match = regexp.match(line) 21 | if match is not None: 22 | return match.group(1) 23 | else: 24 | msg = "Cannot find version in aiozipkin/__init__.py" 25 | raise RuntimeError(msg) 26 | 27 | 28 | classifiers = [ 29 | "License :: OSI Approved :: Apache Software License", 30 | "Intended Audience :: Developers", 31 | "Programming Language :: Python :: 3", 32 | "Programming Language :: Python :: 3.6", 33 | "Programming Language :: Python :: 3.7", 34 | "Programming Language :: Python :: 3.8", 35 | "Programming Language :: Python :: 3.9", 36 | "Operating System :: POSIX", 37 | "Development Status :: 5 - Production/Stable", 38 | "Framework :: AsyncIO", 39 | ] 40 | 41 | 42 | setup( 43 | name="aiozipkin", 44 | version=read_version(), 45 | description=( 46 | "Distributed tracing instrumentation " "for asyncio application with zipkin" 47 | ), 48 | long_description="\n\n".join((read("README.rst"), read("CHANGES.rst"))), 49 | classifiers=classifiers, 50 | platforms=["POSIX"], 51 | author="Nikolay Novik", 52 | author_email="nickolainovik@gmail.com", 53 | url="https://github.com/aio-libs/aiozipkin", 54 | download_url="https://pypi.python.org/pypi/aiozipkin", 55 | license="Apache 2", 56 | packages=find_packages(), 57 | python_requires=">=3.6", 58 | install_requires=install_requires, 59 | keywords=["zipkin", "distributed-tracing", "tracing"], 60 | zip_safe=True, 61 | include_package_data=True, 62 | ) 63 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import gc 3 | from typing import Any, AsyncIterator, Iterator, List, Optional 4 | 5 | import aiohttp 6 | import pytest 7 | from aiohttp import web 8 | from aiohttp.test_utils import TestServer 9 | 10 | from aiozipkin.helpers import TraceContext, create_endpoint 11 | from aiozipkin.sampler import Sampler 12 | from aiozipkin.tracer import Tracer 13 | from aiozipkin.transport import StubTransport 14 | 15 | 16 | @pytest.fixture(scope="session") 17 | def event_loop() -> Iterator[asyncio.AbstractEventLoop]: 18 | asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy()) 19 | loop = asyncio.get_event_loop_policy().new_event_loop() 20 | yield loop 21 | gc.collect() 22 | loop.close() 23 | 24 | 25 | @pytest.fixture(scope="session") 26 | def loop(event_loop: asyncio.AbstractEventLoop) -> asyncio.AbstractEventLoop: 27 | return event_loop 28 | 29 | 30 | @pytest.fixture 31 | def fake_transport() -> Any: 32 | transport = StubTransport() 33 | return transport 34 | 35 | 36 | @pytest.fixture(name="tracer") 37 | def tracer_fixture(fake_transport: Any) -> Tracer: 38 | sampler = Sampler(sample_rate=1.0) 39 | endpoint = create_endpoint("test_service", ipv4="127.0.0.1", port=8080) 40 | # TODO: use context manger at some point 41 | return Tracer(fake_transport, sampler, endpoint) 42 | 43 | 44 | @pytest.fixture 45 | def context() -> TraceContext: 46 | context = TraceContext( 47 | trace_id="6f9a20b5092fa5e144fd15cc31141cd4", 48 | parent_id=None, 49 | span_id="41baf1be2fb9bfc5", 50 | sampled=True, 51 | debug=False, 52 | shared=True, 53 | ) 54 | return context 55 | 56 | 57 | @pytest.fixture 58 | async def client(loop: asyncio.AbstractEventLoop) -> Any: 59 | async with aiohttp.ClientSession() as client: 60 | yield client 61 | 62 | 63 | class FakeZipkin: 64 | def __init__(self) -> None: 65 | self.next_errors: List[Any] = [] 66 | self.app = web.Application() 67 | self.app.router.add_post("/api/v2/spans", self.spans_handler) 68 | self.port = None 69 | self._received_data: List[Any] = [] 70 | self._wait_count: Optional[int] = None 71 | self._wait_fut: Optional[asyncio.Future[None]] = None 72 | 73 | @property 74 | def url(self) -> str: 75 | return "http://127.0.0.1:%s/api/v2/spans" % self.port 76 | 77 | async def spans_handler(self, request: web.Request) -> web.Response: 78 | if len(self.next_errors) > 0: 79 | err = self.next_errors.pop(0) 80 | if err == "disconnect": 81 | assert request.transport is not None 82 | request.transport.close() 83 | await asyncio.sleep(1) 84 | elif err == "timeout": 85 | await asyncio.sleep(60) 86 | return web.HTTPInternalServerError() 87 | 88 | data = await request.json() 89 | if self._wait_count is not None: 90 | self._wait_count -= 1 91 | self._received_data.append(data) 92 | if self._wait_fut is not None and self._wait_count == 0: 93 | self._wait_fut.set_result(None) 94 | 95 | return aiohttp.web.Response(text="", status=200) 96 | 97 | def get_received_data(self) -> List[Any]: 98 | data = self._received_data 99 | self._received_data = [] 100 | return data 101 | 102 | def wait_data(self, count: int) -> "asyncio.Future[Any]": 103 | self._wait_fut = asyncio.Future() 104 | self._wait_count = count 105 | return self._wait_fut 106 | 107 | 108 | @pytest.fixture 109 | async def fake_zipkin(loop: asyncio.AbstractEventLoop) -> AsyncIterator[FakeZipkin]: 110 | zipkin = FakeZipkin() 111 | 112 | server = TestServer(zipkin.app) 113 | await server.start_server() 114 | zipkin.port = server.port # type: ignore[assignment] 115 | 116 | yield zipkin 117 | 118 | await server.close() 119 | 120 | 121 | pytest_plugins = ["docker_fixtures"] 122 | -------------------------------------------------------------------------------- /tests/docker_fixtures.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any 3 | 4 | import aiohttp 5 | import pytest 6 | from aiodocker import Docker 7 | 8 | 9 | def pytest_addoption(parser: Any) -> None: 10 | parser.addoption( 11 | "--no-pull", action="store_true", default=False, help=("Force docker pull") 12 | ) 13 | 14 | 15 | @pytest.fixture(scope="session") 16 | def docker_pull(request: Any) -> bool: 17 | return not request.config.getoption("--no-pull") 18 | 19 | 20 | @pytest.fixture(scope="session") 21 | async def docker() -> Any: 22 | client = Docker() 23 | yield client 24 | await client.close() 25 | 26 | 27 | async def wait_for_response(url: str, delay: float = 0.001) -> None: 28 | last_error = None 29 | async with aiohttp.ClientSession() as session: 30 | for _ in range(100): 31 | try: 32 | async with session.get(url) as response: 33 | data = await response.text() 34 | assert response.status < 500, data 35 | break 36 | except (aiohttp.ClientError, AssertionError) as e: 37 | last_error = e 38 | await asyncio.sleep(delay) 39 | delay *= 2 40 | else: 41 | pytest.fail(f"Cannot start server: {last_error}") 42 | 43 | 44 | @pytest.fixture(scope="session") 45 | async def zipkin_server(docker: Docker, docker_pull: bool) -> Any: 46 | tag = "2" 47 | image = f"openzipkin/zipkin:{tag}" 48 | host = "127.0.0.1" 49 | 50 | if docker_pull: 51 | print(f"Pulling {image} image") 52 | await docker.pull(image) 53 | 54 | container = await docker.containers.create_or_replace( # type: ignore 55 | name=f"zipkin-server-{tag}", 56 | config={ 57 | "Image": image, 58 | "AttachStdout": False, 59 | "AttachStderr": False, 60 | "HostConfig": {"PublishAllPorts": True}, 61 | }, 62 | ) 63 | await container.start() 64 | port = (await container.port(9411))[0]["HostPort"] 65 | 66 | params = dict(host=host, port=port) 67 | 68 | url = f"http://{host}:{port}" 69 | await wait_for_response(url) 70 | 71 | yield params 72 | 73 | await container.kill() 74 | await container.delete(force=True) 75 | 76 | 77 | @pytest.fixture 78 | def zipkin_url(zipkin_server: Any) -> str: 79 | url = "http://{host}:{port}/api/v2/spans".format(**zipkin_server) 80 | return url 81 | 82 | 83 | @pytest.fixture(scope="session") 84 | async def jaeger_server(docker: Docker, docker_pull: bool) -> Any: 85 | # docker run -d -e COLLECTOR_ZIPKIN_HTTP_PORT=9411 \ 86 | # -p5775:5775/udp -p6831:6831/udp -p6832:6832/udp \ 87 | # -p5778:5778 -p16686:16686 -p14268:14268 88 | # -p9411:9411 jaegertracing/all-in-one:latest 89 | 90 | tag = "1.0.0" 91 | image = f"jaegertracing/all-in-one:{tag}" 92 | host = "127.0.0.1" 93 | 94 | if docker_pull: 95 | print(f"Pulling {image} image") 96 | await docker.pull(image) 97 | 98 | container = await docker.containers.create_or_replace( # type: ignore 99 | name=f"jaegertracing-server-{tag}", 100 | config={ 101 | "Image": image, 102 | "AttachStdout": False, 103 | "AttachStderr": False, 104 | "HostConfig": {"PublishAllPorts": True}, 105 | "Env": ["COLLECTOR_ZIPKIN_HTTP_PORT=9411"], 106 | "ExposedPorts": { 107 | "14268/tcp": {}, 108 | "16686/tcp": {}, 109 | "5775/udp": {}, 110 | "5778/tcp": {}, 111 | "6831/udp": {}, 112 | "6832/udp": {}, 113 | "9411/tcp": {}, 114 | }, 115 | }, 116 | ) 117 | await container.start() 118 | 119 | zipkin_port = (await container.port(9411))[0]["HostPort"] 120 | jaeger_port = (await container.port(16686))[0]["HostPort"] 121 | params = dict(host=host, zipkin_port=zipkin_port, jaeger_port=jaeger_port) 122 | 123 | url = f"http://{host}:{zipkin_port}" 124 | await wait_for_response(url) 125 | 126 | yield params 127 | 128 | await container.kill() 129 | await container.delete(force=True) 130 | 131 | 132 | @pytest.fixture 133 | def jaeger_url(jaeger_server: Any) -> str: 134 | url = "http://{host}:{zipkin_port}/api/v2/spans".format(**jaeger_server) 135 | return url 136 | 137 | 138 | @pytest.fixture 139 | def jaeger_api_url(jaeger_server: Any) -> str: 140 | return "http://{host}:{jaeger_port}".format(**jaeger_server) 141 | -------------------------------------------------------------------------------- /tests/test_aiohttp_helpers.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Optional 3 | from unittest.mock import Mock, patch 4 | 5 | import pytest 6 | from aiohttp import web 7 | from aiohttp.test_utils import make_mocked_request 8 | from aiohttp.web_exceptions import HTTPException, HTTPNotFound 9 | 10 | import aiozipkin as az 11 | from aiozipkin.aiohttp_helpers import middleware_maker 12 | 13 | 14 | def test_basic_setup(tracer: az.Tracer) -> None: 15 | app = web.Application() 16 | az.setup(app, tracer) 17 | 18 | fetched_tracer = az.get_tracer(app) 19 | assert len(app.middlewares) == 1 20 | assert tracer is fetched_tracer 21 | 22 | 23 | @pytest.mark.asyncio 24 | async def test_middleware_with_default_transport( 25 | tracer: az.Tracer, fake_transport: Any 26 | ) -> None: 27 | app = web.Application() 28 | az.setup(app, tracer) 29 | 30 | async def handler(request: web.Request) -> web.StreamResponse: 31 | return web.Response(body=b"data") 32 | 33 | req = make_mocked_request("GET", "/aa", headers={"token": "x"}, app=app) 34 | assert req.match_info.route.resource is not None 35 | req.match_info.route.resource.canonical = "/{pid}" # type: ignore[misc] 36 | 37 | middleware = middleware_maker() 38 | await middleware(req, handler) 39 | span = az.request_span(req) 40 | assert span 41 | assert len(fake_transport.records) == 1 42 | 43 | rec = fake_transport.records[0] 44 | assert rec.asdict()["tags"][az.HTTP_ROUTE] == "/{pid}" 45 | 46 | # noop span does not produce records 47 | headers = {"X-B3-Sampled": "0"} 48 | req_noop = make_mocked_request("GET", "/", headers=headers, app=app) 49 | await middleware(req_noop, handler) 50 | span = az.request_span(req_noop) 51 | assert span 52 | assert len(fake_transport.records) == 1 53 | 54 | 55 | @pytest.mark.asyncio 56 | async def test_middleware_with_not_skip_route( 57 | tracer: az.Tracer, fake_transport: Any 58 | ) -> None: 59 | async def handler(request: web.Request) -> web.StreamResponse: 60 | return web.Response(body=b"data") 61 | 62 | app = web.Application() 63 | skip_route = app.router.add_get("/", handler) 64 | az.setup(app, tracer) 65 | 66 | match_info = Mock() 67 | match_info.route = skip_route 68 | 69 | req = make_mocked_request("GET", "/", headers={"token": "x"}, app=app) 70 | req._match_info = match_info 71 | middleware = middleware_maker(skip_routes=[skip_route]) 72 | await middleware(req, handler) 73 | 74 | assert len(fake_transport.records) == 0 75 | 76 | 77 | valid_ips = [ 78 | ("ipv4", "127.0.0.1", None), 79 | ("ipv4", "10.2.14.10", None), 80 | ("ipv4", "255.255.255.1", None), 81 | ("ipv6", "::1", None), 82 | ("ipv6", "2001:cdba:0000:0000::0000:3257:9652", "2001:cdba::3257:9652"), 83 | ("ipv6", "2001:cdba:0:0:0:0:3257:9652", "2001:cdba::3257:9652"), 84 | ("ipv6", "2001:cdba::3257:9652", None), 85 | ("ipv6", "fec0::", None), 86 | ] 87 | 88 | 89 | @pytest.mark.asyncio 90 | @pytest.mark.parametrize("version,address_in,address_out", valid_ips) 91 | async def test_middleware_with_valid_ip( 92 | tracer: az.Tracer, version: str, address_in: str, address_out: Optional[str] 93 | ) -> None: 94 | if address_out is None: 95 | address_out = address_in 96 | 97 | app = web.Application() 98 | az.setup(app, tracer) 99 | 100 | # Fake transport 101 | transp = Mock() 102 | transp.get_extra_info.return_value = (address_in, "0") 103 | 104 | async def handler(request: web.Request) -> web.StreamResponse: 105 | return web.Response(body=b"data") 106 | 107 | req = make_mocked_request( 108 | "GET", "/", headers={"token": "x"}, transport=transp, app=app 109 | ) 110 | 111 | middleware = middleware_maker() 112 | with patch("aiozipkin.span.Span.remote_endpoint") as mocked_remote_ep: 113 | await middleware(req, handler) 114 | 115 | assert mocked_remote_ep.call_count == 1 116 | args, kwargs = mocked_remote_ep.call_args 117 | assert kwargs[version] == address_out 118 | 119 | 120 | invalid_ips = [ 121 | ("ipv4", "127.a.b.1"), 122 | ("ipv4", ".2.14.10"), 123 | ("ipv4", "256.255.255.1"), 124 | ("ipv4", "invalid"), 125 | ("ipv6", ":::"), 126 | ("ipv6", "10000:cdba:0000:0000:0000:0000:3257:9652"), 127 | ("ipv6", "2001:cdba:g:0:0:0:3257:9652"), 128 | ("ipv6", "2001:cdba::3257:9652:"), 129 | ("ipv6", "invalid"), 130 | ] 131 | 132 | 133 | @pytest.mark.asyncio 134 | @pytest.mark.parametrize("version,address", invalid_ips) 135 | async def test_middleware_with_invalid_ip( 136 | tracer: az.Tracer, version: str, address: str 137 | ) -> None: 138 | app = web.Application() 139 | az.setup(app, tracer) 140 | 141 | # Fake transport 142 | transp = Mock() 143 | transp.get_extra_info.return_value = (address, "0") 144 | 145 | async def handler(request: web.Request) -> web.StreamResponse: 146 | return web.Response(body=b"data") 147 | 148 | req = make_mocked_request( 149 | "GET", "/", headers={"token": "x"}, transport=transp, app=app 150 | ) 151 | 152 | middleware = middleware_maker() 153 | with patch("aiozipkin.span.Span.remote_endpoint") as mocked_remote_ep: 154 | await middleware(req, handler) 155 | assert mocked_remote_ep.call_count == 0 156 | 157 | 158 | @pytest.mark.asyncio 159 | async def test_middleware_with_handler_404(tracer: az.Tracer) -> None: 160 | app = web.Application() 161 | az.setup(app, tracer) 162 | 163 | async def handler(request: web.Request) -> web.StreamResponse: 164 | raise HTTPNotFound 165 | 166 | req = make_mocked_request("GET", "/", headers={"token": "x"}, app=app) 167 | 168 | middleware = middleware_maker() 169 | 170 | with pytest.raises(HTTPException): 171 | await middleware(req, handler) 172 | 173 | 174 | @pytest.mark.asyncio 175 | async def test_middleware_cleanup_app(tracer: az.Tracer) -> None: 176 | fut: asyncio.Future[None] = asyncio.Future() 177 | fut.set_result(None) 178 | with patch.object(tracer, "close", return_value=fut) as mocked_close: 179 | app = web.Application() 180 | az.setup(app, tracer) 181 | app.freeze() 182 | await app.cleanup() 183 | assert mocked_close.call_count == 1 184 | -------------------------------------------------------------------------------- /tests/test_aiohttp_integration.py: -------------------------------------------------------------------------------- 1 | from types import SimpleNamespace 2 | from typing import Any 3 | 4 | import aiohttp 5 | import pytest 6 | from aiohttp import web 7 | 8 | import aiozipkin as az 9 | 10 | 11 | async def handler(request: web.Request) -> web.StreamResponse: 12 | span = az.request_span(request) 13 | session = request.app["session"] 14 | 15 | url = "https://httpbin.org/get" 16 | ctx = {"span_context": span.context} 17 | resp = await session.get(url, trace_request_ctx=ctx) 18 | data = await resp.text() 19 | return web.Response(body=data) 20 | 21 | 22 | async def error_handler(request: web.Request) -> web.StreamResponse: 23 | span = az.request_span(request) 24 | session = request.app["session"] 25 | 26 | url = "http://4c2a7f53-9468-43a5-9c7d-466591eda953" 27 | ctx = {"span_context": span.context} 28 | await session.get(url, trace_request_ctx=ctx) 29 | return web.Response(body=b"") 30 | 31 | 32 | @pytest.fixture 33 | async def client(aiohttp_client: Any, tracer: az.Tracer) -> Any: 34 | app = web.Application() 35 | app.router.add_get("/simple", handler) 36 | app.router.add_get("/error", error_handler) 37 | 38 | trace_config = az.make_trace_config(tracer) 39 | session = aiohttp.ClientSession(trace_configs=[trace_config]) 40 | app["session"] = session 41 | 42 | az.setup(app, tracer) 43 | c = await aiohttp_client(app) 44 | yield c 45 | 46 | await session.close() 47 | 48 | 49 | @pytest.mark.asyncio 50 | async def test_handler_with_client_signals( 51 | client: aiohttp.ClientSession, fake_transport: Any 52 | ) -> None: 53 | resp = await client.get("/simple") 54 | assert resp.status == 200 55 | 56 | assert len(fake_transport.records) == 2 57 | 58 | record1 = fake_transport.records[0].asdict() 59 | record2 = fake_transport.records[1].asdict() 60 | assert record1["parentId"] == record2["id"] 61 | assert record2["tags"]["http.status_code"] == "200" 62 | 63 | 64 | @pytest.mark.asyncio 65 | async def test_handler_with_client_signals_error( 66 | client: aiohttp.ClientSession, fake_transport: Any 67 | ) -> None: 68 | resp = await client.get("/error") 69 | assert resp.status == 500 70 | 71 | assert len(fake_transport.records) == 2 72 | record1 = fake_transport.records[0].asdict() 73 | record2 = fake_transport.records[1].asdict() 74 | assert record1["parentId"] == record2["id"] 75 | 76 | msg = "Cannot connect to host" 77 | assert msg in record1["tags"]["error"] 78 | 79 | 80 | @pytest.mark.asyncio 81 | async def test_client_signals(tracer: az.Tracer, fake_transport: Any) -> None: 82 | trace_config = az.make_trace_config(tracer) 83 | session = aiohttp.ClientSession(trace_configs=[trace_config]) 84 | 85 | with tracer.new_trace() as span: 86 | span.name("client:signals") 87 | url = "https://httpbin.org/get" 88 | # do not propagate headers 89 | ctx = {"span_context": span.context, "propagate_headers": False} 90 | resp = await session.get(url, trace_request_ctx=ctx) 91 | data = await resp.read() 92 | assert len(data) > 0 93 | assert az.make_context(resp.request_info.headers) is None 94 | 95 | ctx_ns = SimpleNamespace(span_context=span.context, propagate_headers=False) 96 | resp = await session.get(url, trace_request_ctx=ctx_ns) 97 | data = await resp.read() 98 | assert len(data) > 0 99 | assert az.make_context(resp.request_info.headers) is None 100 | 101 | # by default headers added 102 | ctx = {"span_context": span.context} 103 | resp = await session.get(url, trace_request_ctx=ctx) 104 | await resp.text() 105 | assert len(data) > 0 106 | context = az.make_context(resp.request_info.headers) 107 | assert context is not None 108 | assert context.trace_id == span.context.trace_id 109 | 110 | await session.close() 111 | 112 | assert len(fake_transport.records) == 4 113 | record1 = fake_transport.records[0].asdict() 114 | record2 = fake_transport.records[1].asdict() 115 | record3 = fake_transport.records[2].asdict() 116 | record4 = fake_transport.records[3].asdict() 117 | assert record3["parentId"] == record4["id"] 118 | assert record2["parentId"] == record4["id"] 119 | assert record1["parentId"] == record4["id"] 120 | assert record4["name"] == "client:signals" 121 | 122 | 123 | @pytest.mark.asyncio 124 | async def test_client_signals_no_span(tracer: az.Tracer, fake_transport: Any) -> None: 125 | trace_config = az.make_trace_config(tracer) 126 | session = aiohttp.ClientSession(trace_configs=[trace_config]) 127 | 128 | url = "https://httpbin.org/get" 129 | resp = await session.get(url) 130 | data = await resp.read() 131 | assert len(data) > 0 132 | await session.close() 133 | assert len(fake_transport.records) == 0 134 | 135 | 136 | @pytest.mark.asyncio 137 | async def test_no_resource(client: aiohttp.ClientSession, fake_transport: Any) -> None: 138 | resp = await client.get("/404") 139 | assert resp.status == 404 140 | assert len(fake_transport.records) == 1 141 | record1 = fake_transport.records[0].asdict() 142 | assert record1["tags"]["http.status_code"] == "404" 143 | -------------------------------------------------------------------------------- /tests/test_helpers.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from aiozipkin.helpers import ( 6 | TraceContext, 7 | filter_none, 8 | make_context, 9 | make_headers, 10 | make_single_header, 11 | make_timestamp, 12 | ) 13 | 14 | 15 | @pytest.fixture 16 | def trace_context() -> TraceContext: 17 | new_context = TraceContext( 18 | trace_id="6f9a20b5092fa5e144fd15cc31141cd4", 19 | parent_id=None, 20 | span_id="41baf1be2fb9bfc5", 21 | sampled=True, 22 | debug=False, 23 | shared=False, 24 | ) 25 | return new_context 26 | 27 | 28 | @pytest.fixture 29 | def other_trace_context() -> TraceContext: 30 | context = TraceContext( 31 | trace_id="6f9a20b5092fa5e144fd15cc31141cd4", 32 | parent_id="05e3ac9a4f6e3b90", 33 | span_id="41baf1be2fb9bfc5", 34 | sampled=True, 35 | debug=True, 36 | shared=False, 37 | ) 38 | return context 39 | 40 | 41 | def test_make_headers(trace_context: TraceContext) -> None: 42 | headers = make_headers(trace_context) 43 | expected = { 44 | "X-B3-Flags": "0", 45 | "X-B3-Sampled": "1", 46 | "X-B3-SpanId": "41baf1be2fb9bfc5", 47 | "X-B3-TraceId": "6f9a20b5092fa5e144fd15cc31141cd4", 48 | } 49 | headers2 = trace_context.make_headers() 50 | assert headers == expected == headers2 51 | 52 | 53 | def test_make_single_header( 54 | trace_context: TraceContext, other_trace_context: TraceContext 55 | ) -> None: 56 | headers = make_single_header(trace_context) 57 | expected = {"b3": "6f9a20b5092fa5e144fd15cc31141cd4-41baf1be2fb9bfc5-1"} 58 | headers2 = trace_context.make_single_header() 59 | assert headers == expected == headers2 60 | 61 | headers = make_single_header(other_trace_context) 62 | h = "6f9a20b5092fa5e144fd15cc31141cd4-41baf1be2fb9bfc5-d-05e3ac9a4f6e3b90" 63 | headers2 = other_trace_context.make_single_header() 64 | expected = {"b3": h} 65 | assert headers == expected == headers2 66 | 67 | new_context = trace_context._replace(debug=True, sampled=None) 68 | headers = make_single_header(new_context) 69 | expected = {"b3": "6f9a20b5092fa5e144fd15cc31141cd4-41baf1be2fb9bfc5-d"} 70 | assert headers == expected 71 | 72 | new_context = trace_context._replace(debug=False, sampled=None) 73 | headers = make_single_header(new_context) 74 | expected = {"b3": "6f9a20b5092fa5e144fd15cc31141cd4-41baf1be2fb9bfc5-0"} 75 | assert headers == expected 76 | 77 | 78 | def test_make_context(trace_context: TraceContext) -> None: 79 | headers = make_headers(trace_context) 80 | context = make_context(headers) 81 | assert trace_context == context 82 | 83 | context = make_context({}) 84 | assert context is None 85 | 86 | 87 | def test_make_context_single_header( 88 | trace_context: TraceContext, other_trace_context: TraceContext 89 | ) -> None: 90 | headers = make_single_header(trace_context) 91 | context = make_context(headers) 92 | assert trace_context == context 93 | 94 | headers = make_single_header(other_trace_context) 95 | context = make_context(headers) 96 | assert other_trace_context == context 97 | 98 | headers = {"b3": "0"} 99 | context = make_context(headers) 100 | assert context is None 101 | 102 | headers = {"b3": "6f9a20b5092fa5e144fd15cc31141cd4"} 103 | context = make_context(headers) 104 | assert context is None 105 | 106 | 107 | def test_make_timestamp() -> None: 108 | ts = make_timestamp() 109 | assert len(str(ts)) == 16 110 | 111 | ts = make_timestamp(time.time()) 112 | assert len(str(ts)) == 16 113 | 114 | 115 | def test_filter_none() -> None: 116 | r = filter_none({"a": 1, "b": None}) 117 | assert r == {"a": 1} 118 | 119 | r = filter_none({"a": 1, "b": None, "c": None}, keys=["a", "c"]) 120 | assert r == {"a": 1, "b": None} 121 | 122 | r = filter_none({}, keys=["a", "c"]) 123 | assert r == {} 124 | 125 | r = filter_none({"a": 1, "b": None, "c": {"c": None}}, keys=["a", "c"]) 126 | assert r == {"a": 1, "b": None, "c": {"c": None}} 127 | -------------------------------------------------------------------------------- /tests/test_jaeger.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import aiohttp 4 | import pytest 5 | from yarl import URL 6 | 7 | import aiozipkin as az 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_basic( 12 | jaeger_url: str, 13 | jaeger_api_url: str, 14 | client: aiohttp.ClientSession, 15 | loop: asyncio.AbstractEventLoop, 16 | ) -> None: 17 | endpoint = az.create_endpoint("simple_service", ipv4="127.0.0.1", port=80) 18 | interval = 50 19 | tracer = await az.create( 20 | jaeger_url, 21 | endpoint, 22 | sample_rate=1.0, 23 | send_interval=interval, 24 | ) 25 | 26 | with tracer.new_trace(sampled=True) as span: 27 | span.name("jaeger_span") 28 | span.tag("span_type", "root") 29 | span.kind(az.CLIENT) 30 | span.annotate("SELECT * FROM") 31 | await asyncio.sleep(0.1) 32 | span.annotate("start end sql") 33 | 34 | # close forced sending data to server regardless of send interval 35 | await tracer.close() 36 | trace_id = span.context.trace_id[-16:] 37 | url = URL(jaeger_api_url) / "api" / "traces" / trace_id 38 | resp = await client.get(url, headers={"Content-Type": "application/json"}) 39 | assert resp.status == 200 40 | data = await resp.json() 41 | assert data["data"][0]["traceID"] in trace_id 42 | -------------------------------------------------------------------------------- /tests/test_record.py: -------------------------------------------------------------------------------- 1 | from aiozipkin.helpers import Endpoint, TraceContext 2 | from aiozipkin.record import Record 3 | 4 | 5 | def test_basic_ctr() -> None: 6 | context = TraceContext("string", "string", "string", True, True, True) 7 | local_endpoint = Endpoint("string", "string", "string", 0) 8 | remote_endpoint = Endpoint("string", "string", "string", 0) 9 | record = ( 10 | Record(context, local_endpoint) 11 | .start(0) 12 | .name("string") 13 | .set_tag("additionalProp1", "string") 14 | .set_tag("additionalProp2", "string") 15 | .set_tag("additionalProp3", "string") 16 | .kind("CLIENT") 17 | .annotate("string", 0) 18 | .remote_endpoint(remote_endpoint) 19 | .finish(0) 20 | ) 21 | dict_record = record.asdict() 22 | expected = { 23 | "traceId": "string", 24 | "name": "string", 25 | "parentId": "string", 26 | "id": "string", 27 | "kind": "CLIENT", 28 | "timestamp": 0, 29 | "duration": 1, 30 | "debug": True, 31 | "shared": True, 32 | "localEndpoint": { 33 | "serviceName": "string", 34 | "ipv4": "string", 35 | "ipv6": "string", 36 | "port": 0, 37 | }, 38 | "remoteEndpoint": { 39 | "serviceName": "string", 40 | "ipv4": "string", 41 | "ipv6": "string", 42 | "port": 0, 43 | }, 44 | "annotations": [{"timestamp": 0, "value": "string"}], 45 | "tags": { 46 | "additionalProp1": "string", 47 | "additionalProp2": "string", 48 | "additionalProp3": "string", 49 | }, 50 | } 51 | assert dict_record == expected 52 | -------------------------------------------------------------------------------- /tests/test_sampler.py: -------------------------------------------------------------------------------- 1 | from aiozipkin.sampler import Sampler 2 | 3 | 4 | def test_sample_always() -> None: 5 | sampler = Sampler(sample_rate=1.0) 6 | trace_id = "bde15168450e7097008c7aab41c27ade" 7 | assert sampler.is_sampled(trace_id) 8 | assert sampler.is_sampled(trace_id) 9 | assert sampler.is_sampled(trace_id) 10 | 11 | 12 | def test_sample_never() -> None: 13 | sampler = Sampler(sample_rate=0.0) 14 | trace_id = "bde15168450e7097008c7aab41c27ade" 15 | assert not sampler.is_sampled(trace_id) 16 | assert not sampler.is_sampled(trace_id) 17 | assert not sampler.is_sampled(trace_id) 18 | 19 | 20 | def test_sample_with_rate() -> None: 21 | sampler = Sampler(sample_rate=0.3, seed=123) 22 | trace_id = "bde15168450e7097008c7aab41c27ade" 23 | assert sampler.is_sampled(trace_id) 24 | assert sampler.is_sampled(trace_id) 25 | assert not sampler.is_sampled(trace_id) 26 | -------------------------------------------------------------------------------- /tests/test_tracer.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from unittest import mock 3 | 4 | import pytest 5 | 6 | from aiozipkin.helpers import TraceContext, create_endpoint 7 | from aiozipkin.sampler import SamplerABC 8 | from aiozipkin.span import NoopSpan, Span 9 | from aiozipkin.tracer import Tracer, create_custom 10 | from aiozipkin.transport import StubTransport 11 | 12 | 13 | def test_basic(tracer: Tracer, fake_transport: Any) -> None: 14 | with tracer.new_trace() as span: 15 | span.name("root_span") 16 | span.tag("span_type", "root") 17 | span.kind("CLIENT") 18 | span.annotate("start:sql", ts=1506970524) 19 | span.annotate("end:sql", ts=1506970524) 20 | span.remote_endpoint("service_a", ipv4="127.0.0.1", port=8080) 21 | 22 | assert not span.is_noop 23 | assert span.tracer is tracer 24 | assert span.context.parent_id is None 25 | assert isinstance(span, Span) 26 | assert len(fake_transport.records) == 1 27 | record = fake_transport.records[0] 28 | expected = { 29 | "annotations": [ 30 | {"timestamp": 1506970524000000, "value": "start:sql"}, 31 | {"timestamp": 1506970524000000, "value": "end:sql"}, 32 | ], 33 | "debug": False, 34 | "duration": mock.ANY, 35 | "id": mock.ANY, 36 | "kind": "CLIENT", 37 | "localEndpoint": { 38 | "serviceName": "test_service", 39 | "ipv4": "127.0.0.1", 40 | "port": 8080, 41 | }, 42 | "name": "root_span", 43 | "parentId": None, 44 | "remoteEndpoint": { 45 | "serviceName": "service_a", 46 | "ipv4": "127.0.0.1", 47 | "port": 8080, 48 | }, 49 | "shared": False, 50 | "tags": {"span_type": "root"}, 51 | "timestamp": mock.ANY, 52 | "traceId": mock.ANY, 53 | } 54 | assert record.asdict() == expected 55 | span.finish() 56 | # make sure double finish does not error 57 | span.finish() 58 | 59 | 60 | def test_noop_span_methods(tracer: Tracer) -> Any: 61 | context = TraceContext( 62 | trace_id="6f9a20b5092fa5e144fd15cc31141cd4", 63 | parent_id=None, 64 | span_id="41baf1be2fb9bfc5", 65 | sampled=False, 66 | debug=False, 67 | shared=True, 68 | ) 69 | 70 | with tracer.new_child(context) as span: 71 | span.name("root_span") 72 | span.tag("span_type", "root") 73 | span.kind("CLIENT") 74 | span.annotate("start:sql", ts=1506970524) 75 | span.annotate("end:sql", ts=1506970524) 76 | span.remote_endpoint("service_a", ipv4="127.0.0.1", port=8080) 77 | 78 | with span.new_child() as child_span: 79 | pass 80 | 81 | assert isinstance(span, NoopSpan) 82 | assert span.context.parent_id is not None 83 | assert not span.context.sampled 84 | assert span.is_noop 85 | 86 | span = tracer.to_span(context) 87 | assert isinstance(span, NoopSpan) 88 | assert span.tracer is tracer 89 | 90 | assert isinstance(child_span, NoopSpan) 91 | 92 | 93 | def test_trace_join_span(tracer: Tracer, context: Any) -> None: 94 | 95 | with tracer.join_span(context) as span: 96 | span.name("name") 97 | 98 | assert span.context.trace_id == context.trace_id 99 | assert span.context.span_id == context.span_id 100 | assert span.context.parent_id is None 101 | 102 | new_context = context._replace(sampled=None) 103 | with tracer.join_span(new_context) as span: 104 | span.name("name") 105 | 106 | assert span.context.sampled is not None 107 | 108 | 109 | def test_trace_new_child(tracer: Tracer, context: Any) -> None: 110 | 111 | with tracer.new_child(context) as span: 112 | span.name("name") 113 | 114 | assert span.context.trace_id == context.trace_id 115 | assert span.context.parent_id == context.span_id 116 | assert span.context.span_id is not None 117 | 118 | 119 | def test_span_new_child(tracer: Tracer, context: Any, fake_transport: Any) -> None: 120 | 121 | with tracer.new_child(context) as span: 122 | span.name("name") 123 | with span.new_child("child", "CLIENT") as child_span1: 124 | pass 125 | with span.new_child() as child_span2: 126 | pass 127 | 128 | assert span.context.trace_id == child_span1.context.trace_id 129 | assert span.context.span_id == child_span1.context.parent_id 130 | assert span.context.trace_id == child_span2.context.trace_id 131 | assert span.context.span_id == child_span2.context.parent_id 132 | 133 | record = fake_transport.records[0] 134 | data = record.asdict() 135 | assert data["name"] == "child" 136 | assert data["kind"] == "CLIENT" 137 | 138 | record = fake_transport.records[1] 139 | data = record.asdict() 140 | assert data["name"] == "unknown" 141 | assert "kind" not in data 142 | 143 | 144 | def test_error(tracer: Tracer, fake_transport: Any) -> None: 145 | def func() -> None: 146 | with tracer.new_trace() as span: 147 | span.name("root_span") 148 | raise RuntimeError("boom") 149 | 150 | with pytest.raises(RuntimeError): 151 | func() 152 | 153 | assert len(fake_transport.records) == 1 154 | record = fake_transport.records[0] 155 | 156 | data = record.asdict() 157 | assert data["tags"] == {"error": "boom"} 158 | 159 | 160 | def test_ignored_error(tracer: Tracer, fake_transport: Any) -> None: 161 | tracer._ignored_exceptions.append(RuntimeError) 162 | 163 | def func() -> None: 164 | with tracer.new_trace() as span: 165 | span.name("root_span") 166 | raise RuntimeError("boom") 167 | 168 | with pytest.raises(RuntimeError): 169 | func() 170 | 171 | assert len(fake_transport.records) == 1 172 | record = fake_transport.records[0] 173 | 174 | data = record.asdict() 175 | assert data["tags"] == {} 176 | 177 | 178 | def test_null_annotation(tracer: Tracer, fake_transport: Any) -> None: 179 | with tracer.new_trace() as span: 180 | span.annotate(None, ts=1506970524) 181 | 182 | assert len(fake_transport.records) == 1 183 | record = fake_transport.records[0] 184 | assert record.asdict()["annotations"][0]["value"] == "None" 185 | 186 | 187 | @pytest.mark.asyncio 188 | async def test_create_custom(fake_transport: Any) -> None: 189 | endpoint = create_endpoint("test_service", ipv4="127.0.0.1", port=8080) 190 | 191 | class FakeSampler(SamplerABC): 192 | def is_sampled(self, trace_id: str) -> bool: 193 | return True 194 | 195 | with mock.patch("aiozipkin.tracer.Tracer") as tracer_stub: # type: mock.MagicMock 196 | await create_custom(endpoint, fake_transport, FakeSampler()) 197 | assert isinstance(tracer_stub.call_args[0][0], StubTransport) 198 | assert isinstance(tracer_stub.call_args[0][1], FakeSampler) 199 | -------------------------------------------------------------------------------- /tests/test_transport.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any 3 | 4 | import pytest 5 | from aiohttp.client import ClientTimeout 6 | 7 | import aiozipkin as az 8 | import aiozipkin.transport as azt 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_retry(fake_zipkin: Any, loop: asyncio.AbstractEventLoop) -> None: 13 | endpoint = az.create_endpoint("simple_service", ipv4="127.0.0.1", port=80) 14 | 15 | tr = azt.Transport( 16 | fake_zipkin.url, 17 | send_interval=0.01, 18 | send_max_size=100, 19 | send_attempt_count=3, 20 | send_timeout=ClientTimeout(total=1), 21 | ) 22 | 23 | fake_zipkin.next_errors.append("disconnect") 24 | fake_zipkin.next_errors.append("timeout") 25 | waiter = fake_zipkin.wait_data(1) 26 | 27 | tracer = await az.create_custom(endpoint, tr) 28 | 29 | with tracer.new_trace(sampled=True) as span: 30 | span.name("root_span") 31 | span.kind(az.CLIENT) 32 | 33 | await waiter 34 | await tracer.close() 35 | 36 | data = fake_zipkin.get_received_data() 37 | trace_id = span.context.trace_id 38 | assert any(s["traceId"] == trace_id for trace in data for s in trace), data 39 | 40 | 41 | @pytest.mark.asyncio 42 | async def test_batches(fake_zipkin: Any, loop: asyncio.AbstractEventLoop) -> None: 43 | endpoint = az.create_endpoint("simple_service", ipv4="127.0.0.1", port=80) 44 | 45 | tr = azt.Transport( 46 | fake_zipkin.url, 47 | send_interval=0.01, 48 | send_max_size=2, 49 | send_timeout=ClientTimeout(total=1), 50 | ) 51 | 52 | tracer = await az.create_custom(endpoint, tr) 53 | 54 | with tracer.new_trace(sampled=True) as span: 55 | span.name("root_span") 56 | span.kind(az.CLIENT) 57 | with span.new_child("child_1", az.CLIENT): 58 | pass 59 | with span.new_child("child_2", az.CLIENT): 60 | pass 61 | 62 | # close forced sending data to server regardless of send interval 63 | await tracer.close() 64 | 65 | data = fake_zipkin.get_received_data() 66 | trace_id = span.context.trace_id 67 | assert len(data[0]) == 2 68 | assert len(data[1]) == 1 69 | assert data[0][0]["name"] == "child_1" 70 | assert data[0][1]["name"] == "child_2" 71 | assert data[1][0]["name"] == "root_span" 72 | assert any(s["traceId"] == trace_id for trace in data for s in trace), data 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_send_full_batch( 77 | fake_zipkin: Any, loop: asyncio.AbstractEventLoop 78 | ) -> None: 79 | endpoint = az.create_endpoint("simple_service", ipv4="127.0.0.1", port=80) 80 | 81 | tr = azt.Transport( 82 | fake_zipkin.url, 83 | send_interval=60, 84 | send_max_size=2, 85 | send_timeout=ClientTimeout(total=1), 86 | ) 87 | 88 | tracer = await az.create_custom(endpoint, tr) 89 | waiter = fake_zipkin.wait_data(1) 90 | 91 | with tracer.new_trace(sampled=True) as span: 92 | span.name("root_span") 93 | span.kind(az.CLIENT) 94 | 95 | await asyncio.sleep(1) 96 | 97 | data = fake_zipkin.get_received_data() 98 | assert len(data) == 0 99 | 100 | with tracer.new_trace(sampled=True) as span: 101 | span.name("root_span") 102 | span.kind(az.CLIENT) 103 | 104 | # batch is full here 105 | await waiter 106 | data = fake_zipkin.get_received_data() 107 | assert len(data) == 1 108 | 109 | # close forced sending data to server regardless of send interval 110 | await tracer.close() 111 | 112 | 113 | @pytest.mark.asyncio 114 | async def test_lost_spans(fake_zipkin: Any, loop: asyncio.AbstractEventLoop) -> None: 115 | endpoint = az.create_endpoint("simple_service", ipv4="127.0.0.1", port=80) 116 | 117 | tr = azt.Transport( 118 | fake_zipkin.url, 119 | send_interval=0.01, 120 | send_max_size=100, 121 | send_attempt_count=2, 122 | send_timeout=ClientTimeout(total=1), 123 | ) 124 | 125 | fake_zipkin.next_errors.append("disconnect") 126 | fake_zipkin.next_errors.append("disconnect") 127 | 128 | tracer = await az.create_custom(endpoint, tr) 129 | 130 | with tracer.new_trace(sampled=True) as span: 131 | span.name("root_span") 132 | span.kind(az.CLIENT) 133 | 134 | await asyncio.sleep(1) 135 | 136 | await tracer.close() 137 | 138 | data = fake_zipkin.get_received_data() 139 | assert len(data) == 0 140 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from aiozipkin import utils 4 | 5 | 6 | @mock.patch("aiozipkin.utils.random.getrandbits", autospec=True) 7 | def test_generate_random_64bit_string(rand: mock.Mock) -> None: 8 | rand.return_value = 0x17133D482BA4F605 9 | random_string = utils.generate_random_64bit_string() 10 | assert random_string == "17133d482ba4f605" 11 | # This acts as a contract test of sorts. This should return a str 12 | # in both py2 and py3. IOW, no unicode objects. 13 | assert isinstance(random_string, str) 14 | 15 | 16 | @mock.patch("aiozipkin.utils.time.time", autospec=True) 17 | @mock.patch("aiozipkin.utils.random.getrandbits", autospec=True) 18 | def test_generate_random_128bit_string(rand: mock.Mock, mock_time: mock.Mock) -> None: 19 | rand.return_value = 0x2BA4F60517133D482BA4F605 20 | mock_time.return_value = float(0x17133D48) 21 | random_string = utils.generate_random_128bit_string() 22 | assert random_string == "17133d482ba4f60517133d482ba4f605" 23 | rand.assert_called_once_with(96) # 96 bits 24 | # This acts as a contract test of sorts. This should return a str 25 | # in both py2 and py3. IOW, no unicode objects. 26 | assert isinstance(random_string, str) 27 | 28 | 29 | def test_unsigned_hex_to_signed_int() -> None: 30 | assert utils.unsigned_hex_to_signed_int("17133d482ba4f605") == 1662740067609015813 31 | assert utils.unsigned_hex_to_signed_int("b6dbb1c2b362bf51") == -5270423489115668655 32 | 33 | 34 | def test_signed_int_to_unsigned_hex() -> None: 35 | assert utils.signed_int_to_unsigned_hex(1662740067609015813) == "17133d482ba4f605" 36 | assert utils.signed_int_to_unsigned_hex(-5270423489115668655) == "b6dbb1c2b362bf51" 37 | 38 | with mock.patch("builtins.hex") as mock_hex: 39 | mock_hex.return_value = "0xb6dbb1c2b362bf51L" 40 | assert ( 41 | utils.signed_int_to_unsigned_hex(-5270423489115668655) == "b6dbb1c2b362bf51" 42 | ) 43 | -------------------------------------------------------------------------------- /tests/test_zipkin.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import gc 3 | import logging 4 | import tracemalloc 5 | from typing import Any, Union 6 | 7 | import aiohttp 8 | import pytest 9 | from yarl import URL 10 | 11 | import aiozipkin as az 12 | 13 | 14 | async def _retry_zipkin_client( 15 | url: Union[str, URL], 16 | client: aiohttp.ClientSession, 17 | retries: int = 5, 18 | backoff_time: int = 1, 19 | ) -> Any: 20 | tries = 0 21 | while tries < retries: 22 | await asyncio.sleep(backoff_time) 23 | resp = await client.get(url) 24 | if resp.status > 200: 25 | tries += 1 26 | continue 27 | data = await resp.json() 28 | return data 29 | raise RuntimeError("Unreachable") 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_basic( 34 | zipkin_url: str, client: aiohttp.ClientSession, loop: asyncio.AbstractEventLoop 35 | ) -> None: 36 | endpoint = az.create_endpoint("simple_service", ipv4="127.0.0.1", port=80) 37 | interval = 50 38 | tracer = await az.create( 39 | zipkin_url, 40 | endpoint, 41 | sample_rate=1.0, 42 | send_interval=interval, 43 | ) 44 | 45 | with tracer.new_trace(sampled=True) as span: 46 | span.name("root_span") 47 | span.tag("span_type", "root") 48 | span.kind(az.CLIENT) 49 | span.annotate("SELECT * FROM") 50 | await asyncio.sleep(0.1) 51 | span.annotate("start end sql") 52 | 53 | # close forced sending data to server regardless of send interval 54 | await tracer.close() 55 | 56 | trace_id = span.context.trace_id 57 | url = URL(zipkin_url).with_path("/zipkin/api/v2/traces") 58 | data = await _retry_zipkin_client(url, client) 59 | assert any(s["traceId"] == trace_id for trace in data for s in trace), data 60 | 61 | 62 | async def test_basic_context_manager( 63 | zipkin_url: str, client: aiohttp.ClientSession, loop: asyncio.AbstractEventLoop 64 | ) -> None: 65 | endpoint = az.create_endpoint("simple_service", ipv4="127.0.0.1", port=80) 66 | interval = 50 67 | async with az.create( 68 | zipkin_url, endpoint, sample_rate=1.0, send_interval=interval 69 | ) as tracer: 70 | with tracer.new_trace(sampled=True) as span: 71 | span.name("root_span") 72 | await asyncio.sleep(0.1) 73 | 74 | trace_id = span.context.trace_id 75 | url = URL(zipkin_url).with_path("/zipkin/api/v2/traces") 76 | data = await _retry_zipkin_client(url, client) 77 | 78 | assert any(s["traceId"] == trace_id for trace in data for s in trace), data 79 | 80 | 81 | @pytest.mark.asyncio 82 | async def test_exception_in_span( 83 | zipkin_url: str, client: aiohttp.ClientSession, loop: asyncio.AbstractEventLoop 84 | ) -> None: 85 | endpoint = az.create_endpoint("error_service", ipv4="127.0.0.1", port=80) 86 | interval = 50 87 | async with az.create( 88 | zipkin_url, 89 | endpoint, 90 | send_interval=interval, 91 | ) as tracer: 92 | 93 | def func(span: az.SpanAbc) -> None: 94 | with span: 95 | span.name("root_span") 96 | raise RuntimeError("foo") 97 | 98 | span = tracer.new_trace(sampled=True) 99 | with pytest.raises(RuntimeError): 100 | func(span) 101 | 102 | url = URL(zipkin_url).with_path("/zipkin/api/v2/traces") 103 | data = await _retry_zipkin_client(url, client) 104 | assert any({"error": "foo"} == s.get("tags", {}) for trace in data for s in trace) 105 | 106 | 107 | @pytest.mark.asyncio 108 | async def test_zipkin_error( 109 | client: aiohttp.ClientSession, loop: asyncio.AbstractEventLoop, caplog: Any 110 | ) -> None: 111 | endpoint = az.create_endpoint("error_service", ipv4="127.0.0.1", port=80) 112 | interval = 50 113 | zipkin_url = "https://httpbin.org/status/404" 114 | async with az.create( 115 | zipkin_url, 116 | endpoint, 117 | sample_rate=1.0, 118 | send_interval=interval, 119 | ) as tracer: 120 | with tracer.new_trace(sampled=True) as span: 121 | span.kind(az.CLIENT) 122 | await asyncio.sleep(0.0) 123 | 124 | assert len(caplog.records) == 1 125 | msg = "zipkin responded with code: " 126 | assert msg in str(caplog.records[0].exc_info) 127 | 128 | t = ("aiozipkin", logging.ERROR, "Can not send spans to zipkin") 129 | assert caplog.record_tuples == [t] 130 | 131 | 132 | @pytest.mark.asyncio 133 | async def test_leak_in_transport( 134 | zipkin_url: str, client: aiohttp.ClientSession, loop: asyncio.AbstractEventLoop 135 | ) -> None: 136 | 137 | tracemalloc.start() 138 | 139 | endpoint = az.create_endpoint("simple_service") 140 | tracer = await az.create( 141 | zipkin_url, 142 | endpoint, 143 | sample_rate=1, 144 | send_interval=0.0001, 145 | ) 146 | 147 | await asyncio.sleep(5) 148 | gc.collect() 149 | snapshot1 = tracemalloc.take_snapshot() 150 | 151 | await asyncio.sleep(10) 152 | gc.collect() 153 | snapshot2 = tracemalloc.take_snapshot() 154 | 155 | top_stats = snapshot2.compare_to(snapshot1, "lineno") 156 | count = sum(s.count for s in top_stats) 157 | await tracer.close() 158 | assert count < 400 # in case of leak this number is around 901452 159 | --------------------------------------------------------------------------------