├── .coveragerc ├── .github └── workflows │ ├── build.yaml │ └── pypi.yaml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGELOG.rst ├── LICENSE.txt ├── MANIFEST.in ├── Makefile ├── README.md ├── docs └── source │ ├── changelog.rst │ ├── conf.py │ ├── configuring_zipkin.rst │ ├── index.rst │ └── pyramid_zipkin.rst ├── mypy.ini ├── pyramid_zipkin ├── __init__.py ├── py.typed ├── request_helper.py ├── tween.py ├── version.py └── zipkin.py ├── requirements-dev.txt ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── acceptance │ ├── __init__.py │ ├── app │ │ └── __init__.py │ ├── conftest.py │ ├── server_span_test.py │ ├── span_context_test.py │ └── test_helper.py ├── conftest.py ├── request_helper_test.py └── tween_test.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = 4 | . 5 | omit = 6 | .tox/* 7 | /usr/* 8 | */tmp* 9 | setup.py 10 | # Don't complain if non-runnable code isn't run 11 | */__main__.py 12 | 13 | # Code generated by thrift 14 | pyramid_zipkin/zipkinCore/* 15 | 16 | [report] 17 | exclude_lines = 18 | # Have to re-enable the standard pragma 19 | \#\s*pragma: no cover 20 | 21 | # Don't complain if tests don't hit defensive assertion code: 22 | ^\s*raise AssertionError\b 23 | ^\s*raise NotImplementedError\b 24 | ^\s*return NotImplemented\b 25 | ^\s*raise$ 26 | 27 | # Don't complain if non-runnable code isn't run: 28 | ^if __name__ == ['"]__main__['"]:$ 29 | 30 | [html] 31 | directory = coverage-html 32 | 33 | # vim:ft=dosini 34 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | tags: 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-22.04 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | python-version: ['3.8', '3.10', '3.11', '3.12'] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: actions/setup-python@v2 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | 23 | - name: Install dependencies 24 | run: python -m pip install tox 25 | 26 | - name: Run ${{ matrix.python-version }} tox 27 | run: tox -e py 28 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yaml: -------------------------------------------------------------------------------- 1 | name: Publish on PyPI 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - uses: actions/setup-python@v4 15 | with: 16 | python-version: 3.8 17 | 18 | - name: Install Python dependencies 19 | run: pip install wheel 20 | 21 | - name: Create a Wheel file and source distribution 22 | run: python setup.py sdist bdist_wheel 23 | 24 | - name: Publish distribution package to PyPI 25 | uses: pypa/gh-action-pypi-publish@v1.5.1 26 | with: 27 | user: __token__ 28 | password: ${{ secrets.PYPI_PASSWORD }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache/ 2 | *.egg-info 3 | *.pyc 4 | .coverage 5 | .pytest_cache 6 | .tox 7 | build/ 8 | __pycache__ 9 | dist/ 10 | venv 11 | tags 12 | *.swp 13 | .idea/ 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.5.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-json 8 | files: \.(bowerrc|jshintrc|json)$ 9 | - id: check-yaml 10 | - id: debug-statements 11 | - id: name-tests-test 12 | exclude: tests/acceptance/test_helper.py 13 | - id: requirements-txt-fixer 14 | - repo: https://github.com/pycqa/flake8 15 | rev: 7.0.0 16 | hooks: 17 | - id: flake8 18 | args: 19 | - --max-line-length=82 20 | exclude: docs/source/conf.py 21 | - repo: https://github.com/asottile/reorder_python_imports.git 22 | rev: v0.3.5 23 | hooks: 24 | - id: reorder-python-imports 25 | language_version: python3.8 26 | - repo: https://github.com/asottile/pyupgrade 27 | rev: v2.38.2 28 | hooks: 29 | - id: pyupgrade 30 | args: ['--py38-plus'] 31 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | 3.0.1 (2024-09-25) 2 | ------------------ 3 | - Bump from 3.0.0 to fix build 4 | 5 | 3.0.0 (2024-09-24) 6 | ------------------ 7 | - Generate 128-bit trace IDs by default. 64-bit trace IDs will be transparently 8 | forwarded if received in the X-B3-TraceId header. 9 | - Remove support for '0x' and '-0x' prefixes in trace IDs 10 | - Remove the `zipkin.trace_id_generator` setting 11 | 12 | 2.3.0 (2024-09-24) 13 | ------------------- 14 | - Use environ dict object from request object to populate zipin span attributes 15 | 16 | 2.2.1 (2024-07-04) 17 | ------------------- 18 | - Requires Python >= 3.8 19 | - Adds OTels semantic conventions for http server span attributes 20 | - `http.request.method` 21 | - `network.protocol.version` 22 | - `url.path` 23 | - `server.address` 24 | - `server.port` 25 | - `url.scheme` 26 | - `user_agent.original` 27 | - `url.query` 28 | - `client.address` 29 | - `http.response.status_code` 30 | - `otel.status_code` 31 | - `otel.library.name` 32 | - `otel.library.version` 33 | - Adds the Zipkin Annotation (OTel Event) when an exception occures as the type of exception 34 | - Adds `exception.stacktrace` as an attribute if an exception occurs 35 | 36 | 2.1.3 (2023-10-09) 37 | ------------------- 38 | - Handle exceptions during request processing to set response_status_code=500 and error.type 39 | 40 | 2.1.2 (2023-01-25) 41 | ------------------- 42 | - Use V2_JSON zipkin encoding by default 43 | - Updated README.md 44 | 45 | 46 | 1.0.1 (2021-10-28) 47 | ------------------- 48 | - Fixed a flake8 formatting related failure from 1.0.0 49 | 50 | 1.0.0 (2021-10-27) 51 | ------------------- 52 | - The post_handler_hook api is changed to pass the zipkin_span context 53 | so users can add more content to the span during post processing 54 | 55 | 56 | 0.27.0 (2020-04-07) 57 | ------------------- 58 | - Change tween ordering to be close to INGRESS rather than EXCVIEW 59 | 60 | 0.26.0 (2019-11-14) 61 | ------------------- 62 | - Add zipkin_span_id to the request object. 63 | 64 | 0.25.1 (2019-09-16) 65 | ------------------- 66 | - Path and route blacklists now work also for firehose traces 67 | 68 | 0.25.0 (2019-02-28) 69 | ------------------- 70 | - Remove `zipkin.always_emit_zipkin_headers` config option. Rather than using 71 | set_property to add the trace_id to the request, we now do a simple assignment 72 | which is way faster and removes the need for that flag. 73 | 74 | 0.24.1 (2019-02-25) 75 | ------------------- 76 | - Update tween to support py-zipkin 0.18+ 77 | 78 | 0.24.0 (2019-01-29) 79 | ------------------- 80 | - Add `zipkin.encoding` param to allow specifying the output span encoding. 81 | 82 | 0.23.0 (2018-12-11) 83 | ------------------- 84 | - Add post_handler_hook to the tween. 85 | 86 | 0.22.0 (2018-10-02) 87 | ------------------- 88 | - Set `zipkin.use_pattern_as_span_name` to use the pyramid route pattern 89 | as span_name rather than the raw url. 90 | - Requires py_zipkin >= 0.13.0. 91 | 92 | 0.21.1 (2018-06-03) 93 | ------------------- 94 | - Use renamed py_zipkin.storage interface 95 | - Remove deprecated logger.debug() usage in tests 96 | - Require py_zipkin >= 0.13.0 97 | 98 | 0.21.0 (2018-03-09) 99 | ------------------- 100 | - Added support for http.route annotation 101 | 102 | 0.20.3 (2018-03-08) 103 | ------------------- 104 | - Add max_span_batch_size to the zipkin tween settings. 105 | 106 | 0.20.2 (2018-02-20) 107 | ------------------- 108 | - Require py_zipkin >= 0.11.0 109 | 110 | 0.20.1 (2018-02-13) 111 | ------------------- 112 | - Added support for experimental firehose mode 113 | 114 | 0.20.0 (2018-02-09) 115 | ------------------- 116 | - Added support for using a request-specific stack for storing zipkin attributes. 117 | 118 | 0.19.2 (2017-08-17) 119 | ------------------- 120 | - Trace context is again propagated for non-sampled requests. 121 | 122 | 0.19.1 (2017-06-02) 123 | ------------------- 124 | - Modified tween.py to include host and port when creating a zipkin span. 125 | 126 | 0.19.0 (2017-06-01) 127 | ------------------- 128 | - Added zipkin.always_emit_zipkin_headers config flag. 129 | - Skip zipkin_span context manager if the request is not being sampled 130 | to improve performance and avoid unnecessary work. 131 | 132 | 0.18.2 (2017-03-06) 133 | ------------------- 134 | - Using new update_binary_annotations functions from py_zipkin. 135 | - Requires py_zipkin >= 0.7.0 136 | 137 | 0.18.1 (2017-02-06) 138 | ------------------- 139 | - No changes 140 | 141 | 0.18.0 (2017-02-06) 142 | ------------------- 143 | - Add automatic timestamp/duration reporting for root server span. Also added 144 | functionality for individual services to override this setting in configuration. 145 | 146 | 0.17.0 (2016-12-16) 147 | ------------------- 148 | - Add registry setting to force py-zipkin to add a logging annotation to server 149 | traces. Requires py-zipkin >= 0.4.4. 150 | 151 | 0.16.1 (2016-10-14) 152 | ------------------- 153 | - support for configuring custom versions of create_zipkin_attr and is_tracing 154 | through the pyramid registry. 155 | 156 | 0.16.0 (2016-10-06) 157 | ------------------- 158 | - Fix sample rate bug and make sampling be random and not depend on request id. 159 | 160 | 0.15.0 (2016-09-29) 161 | ------------------- 162 | - Make `get_trace_id` function more defensive about what types of trace 163 | ids it accepts. Converts "0x..." and "-0x..." IDs to remove the leading 164 | "Ox"s 165 | 166 | 0.14.0 (2016-09-29) 167 | ------------------- 168 | - Make `zipkin.transport_handler` a function that takes two arguments, a 169 | stream_name and a message. 170 | 171 | 0.13.1 (2016-09-21) 172 | ------------------- 173 | - Alias `create_headers_for_new_span` to `create_http_headers_for_new_span` 174 | for backwards compatibility. 175 | 176 | 0.13.0 (2016-09-12) 177 | ------------------- 178 | - Moved non-pyramid and zipkin-only code to py_zipkin package 179 | - 'zipkin.transport_handler' now only takes a single message parameter 180 | - `create_headers_for_new_span` is moved to py_zipkin and renamed to 181 | `create_http_headers_for_new_span` 182 | 183 | 0.12.3 (2016-07-27) 184 | ------------------- 185 | - Fix coverage command invocation to be compatible with coverage v4.2 186 | 187 | 0.12.2 (2016-07-15) 188 | ------------------- 189 | - make "service_name" default to "unknown" when not found in registry 190 | 191 | 0.12.1 (2016-07-08) 192 | ------------------- 193 | - Add @zipkin_span decorator for logging functions as spans 194 | 195 | 0.11.1 (2016-04-28) 196 | ------------------- 197 | - Binary annotation values are converted to str 198 | - Removed restriction where only successful status codes are logged 199 | - Added status code as a default binary annotation 200 | - Prevent errors when ZipkinAttrs doesn't exist (usually in multithreaded environments) 201 | - pyramid-zipkin is a pure python package 202 | 203 | 0.11.0 (2016-04-19) 204 | ------------------- 205 | - Renames ClientSpanContext to SpanContext, adds 'ss' and 'sr' annotations. 206 | 207 | 0.10.0 (2016-04-12) 208 | ------------------- 209 | - Always generate ZipkinAttrs, even when a request isn't sampled. 210 | 211 | 0.9.2 (2016-04-07) 212 | ------------------ 213 | - Don't set parent_span_id on root span 214 | 215 | 0.9.1 (2016-03-29) 216 | ------------------ 217 | - Made generate_random_64bit_string always return str, not unicode 218 | 219 | 0.9.0 (2016-03-27) 220 | ------------------ 221 | - Fixed bug where headers were not 64-bit unsigned hex strings. 222 | - Added ClientSpanContext, that lets users log arbitrary trees of 223 | client spans. 224 | - Deprecates "is_client=True" debug logging key in favor of a 225 | non-None "service_name" key for indicating that a span logged 226 | is a new client span. 227 | - Batches up additional annotations in client before sending 228 | to the collector. 229 | 230 | 0.8.1 (2016-03-02) 231 | ------------------ 232 | - Spans without a span ID will generate a new span ID by default. 233 | 234 | 0.8.0 (2016-03-01) 235 | ------------------ 236 | - Add ability to override "service_name" attribute when logging client 237 | spans. 238 | 239 | 0.7.1 (2016-02-26) 240 | ------------------ 241 | - Don't re-compile path regexes 242 | 243 | 0.7.0 (2016-02-24) 244 | ------------------ 245 | - Don't enter ZipkinLoggingContext if request is not sampled. 246 | 247 | 0.6.0 (2016-02-06) 248 | ------------------ 249 | - Fix bug which was squashing identical span names. 250 | - over=EXCVIEW ordering instead of over=MAIN 251 | 252 | 0.5.0 (2016-01-14) 253 | ------------------ 254 | - Add support for `set_extra_binary_annotations` callback. 255 | 256 | 0.4.0 (2016-01-07) 257 | ------------------ 258 | - Add `http.uri.qs` annotation which includes query string, `http.uri` doesn't. 259 | 260 | 0.3.0 (2015-12-29) 261 | ------------------ 262 | - Change config parameters to be generic for scribe/kafka transport. 263 | 264 | 0.2.2 (2015-12-09) 265 | ------------------ 266 | - Compatible with py33, py34. Replaced Thrift with thriftpy. 267 | 268 | 0.1.2 (2015-12-03) 269 | ------------------ 270 | - Re-assign empty list to threading_local.requests if attr not present instead of 271 | globally assigning empty list. 272 | 273 | 0.1.0 (2015-11-08) 274 | ------------------ 275 | - pyramid-zipkin setup. 276 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 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 2015 Yelp Inc 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: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/pyramid_zipkin/55974e359834fadd62c350e167fe47f12c6d6a8f/MANIFEST.in -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all install test tests clean docs install-hooks 2 | 3 | all: test 4 | 5 | build: 6 | ./setup.py bdist_egg 7 | 8 | dev: clean 9 | ./setup.py develop 10 | 11 | install: 12 | pip install . 13 | 14 | install-hooks: 15 | tox -e pre-commit -- install -f --install-hooks 16 | 17 | test: 18 | tox 19 | 20 | tests: test 21 | 22 | docs: 23 | tox -e docs 24 | 25 | clean: 26 | @rm -rf .tox build dist docs/build *.egg-info 27 | find . -name '*.pyc' -delete 28 | find . -name '__pycache__' -delete 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Coverage Status](https://img.shields.io/coveralls/Yelp/pyramid_zipkin.svg)](https://coveralls.io/r/Yelp/pyramid_zipkin) 2 | [![PyPi version](https://img.shields.io/pypi/v/pyramid_zipkin.svg)](https://pypi.python.org/pypi/pyramid_zipkin/) 3 | [![Supported Python versions](https://img.shields.io/pypi/pyversions/pyramid_zipkin.svg)](https://pypi.python.org/pypi/pyramid_zipkin/) 4 | 5 | pyramid_zipkin 6 | -------------- 7 | 8 | This project provides [Zipkin](https://github.com/openzipkin/zipkin/wiki) instrumentation 9 | for the [Pyramid](http://docs.pylonsproject.org/en/latest/docs/pyramid.html) framework by 10 | using [py_zipkin](https://github.com/Yelp/py_zipkin) under the hood. 11 | 12 | Full documentation [here](http://pyramid-zipkin.readthedocs.org/en/latest/). 13 | 14 | Features include: 15 | 16 | * Blacklisting specific route/paths from getting traced. 17 | 18 | * `zipkin_tracing_percent` to control the percentage of requests getting sampled (starting at, and downstream from, this service). 19 | 20 | * Creates `http.uri`, `http.uri.qs`, and `status_code` binary annotations automatically for each trace. 21 | 22 | Install 23 | ------- 24 | 25 | ``` 26 | pip install pyramid_zipkin 27 | ``` 28 | 29 | Usage 30 | ----- 31 | 32 | In your service's webapp, you need to include: 33 | 34 | ``` 35 | config.include('pyramid_zipkin') 36 | ``` 37 | 38 | ## Deployment 39 | To bump and deploy a new version after changes have been merged into master, follow these steps: 40 | - `$ git checkout master && git pull` 41 | - update `CHANGELOG.rst` to document the changes 42 | - update `__version__` in `pyramid_zipkin/version.py` 43 | - `$ git add CHANGELOG.rst pyramid_zipkin/version.py && git commit -m 'version '` 44 | - `$ git tag v` 45 | - `$ git push origin master --tags` 46 | 47 | License 48 | ------- 49 | 50 | Copyright (c) 2023, Yelp, Inc. All rights reserved. Apache v2 51 | -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | ../../CHANGELOG.rst -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -- General configuration ----------------------------------------------- 2 | 3 | extensions = [ 4 | 'sphinx.ext.autodoc', 5 | 'sphinx.ext.doctest', 6 | 'sphinx.ext.intersphinx', 7 | ] 8 | 9 | release = "0.19.2" 10 | 11 | # Add any paths that contain templates here, relative to this directory. 12 | templates_path = ['_templates'] 13 | 14 | # The suffix of source filenames. 15 | source_suffix = '.rst' 16 | 17 | # The master toctree document. 18 | master_doc = 'index' 19 | 20 | # General information about the project. 21 | project = 'pyramid_zipkin' 22 | copyright = '2018, Yelp, Inc' 23 | 24 | exclude_patterns = [] 25 | 26 | pygments_style = 'sphinx' 27 | 28 | 29 | # -- Options for HTML output --------------------------------------------- 30 | 31 | html_theme = 'sphinxdoc' 32 | 33 | html_static_path = ['_static'] 34 | 35 | htmlhelp_basename = 'zipkin-pydoc' 36 | 37 | 38 | intersphinx_mapping = { 39 | 'http://docs.python.org/': None 40 | } 41 | -------------------------------------------------------------------------------- /docs/source/configuring_zipkin.rst: -------------------------------------------------------------------------------- 1 | Configuring pyramid_zipkin 2 | ========================== 3 | 4 | Required configuration settings 5 | ------------------------------- 6 | 7 | zipkin.transport_handler 8 | ~~~~~~~~~~~~~~~~~~~~~~~~ 9 | A callback function which takes a single `message data` parameter. 10 | A sample method can be something like this: 11 | 12 | A) FOR `kafka` TRANSPORT: 13 | 14 | .. code-block:: python 15 | 16 | from kafka import SimpleProducer, KafkaClient 17 | 18 | def kafka_handler(stream_name, message): 19 | 20 | kafka = KafkaClient('{0}:{1}'.format('localhost', 9092)) 21 | producer = SimpleProducer(kafka, async=True) 22 | producer.send_messages(stream_name, message) 23 | 24 | The above example uses python package `kafka-python `_. 25 | 26 | B) FOR `scribe` TRANSPORT: 27 | 28 | .. code-block:: python 29 | 30 | import base64 31 | from scribe import scribe 32 | from thrift.transport import TTransport, TSocket 33 | from thrift.protocol import TBinaryProtocol 34 | 35 | def scribe_handler(stream_name, message): 36 | socket = TSocket.TSocket(host="HOST", port=9999) 37 | transport = TTransport.TFramedTransport(socket) 38 | protocol = TBinaryProtocol.TBinaryProtocol( 39 | trans=transport, strictRead=False, strictWrite=False) 40 | client = scribe.Client(protocol) 41 | transport.open() 42 | 43 | message_b64 = base64.b64encode(message).strip() 44 | log_entry = scribe.LogEntry(stream_name, message_b64) 45 | result = client.Log(messages=[log_entry]) 46 | if result == 0: 47 | print 'success' 48 | 49 | The above example uses python package 50 | `facebook-scribe `_ 51 | for the scribe APIs but any similar package can do the work. 52 | 53 | 54 | Optional configuration settings 55 | ------------------------------- 56 | 57 | All below settings are optional and have a sane default functionality set. 58 | These can be used to fine tune as per your use case. 59 | 60 | service_name 61 | ~~~~~~~~~~~~~~~ 62 | A string representing the name of the service under instrumentation. 63 | It defaults to `unknown`. 64 | 65 | 66 | zipkin.blacklisted_paths 67 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 68 | A list of paths as strings, regex strings, or compiled regexes, any of 69 | which if matched with the request path will not be sampled. Pre-compiled 70 | regexes will be the fastest. Defaults to `[]`. Example: 71 | 72 | .. code-block:: python 73 | 74 | 'zipkin.blacklisted_paths': [r'^/status/?',] 75 | 76 | 77 | zipkin.blacklisted_routes 78 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 79 | A list of routes as strings any of which if matched with the request route 80 | will not be sampled. Defaults to `[]`. Example: 81 | 82 | .. code-block:: python 83 | 84 | 'zipkin.blacklisted_routes': ['some_internal_route',] 85 | 86 | 87 | zipkin.host 88 | ~~~~~~~~~~~~~~~~~~ 89 | The host ip that is used for zipkin spans. If not given, host will be 90 | automatically determined. 91 | 92 | 93 | zipkin.port 94 | ~~~~~~~~~~~~~~~~~~ 95 | The port that is used for zipkin spans. If not given, port will be 96 | automatically determined. 97 | 98 | 99 | zipkin.stream_name 100 | ~~~~~~~~~~~~~~~~~~ 101 | A log name to log Zipkin spans to. Defaults to 'zipkin'. 102 | 103 | 104 | zipkin.tracing_percent 105 | ~~~~~~~~~~~~~~~~~~~~~~ 106 | A number between 0.0 and 100.0 to control how many request calls get sampled. 107 | 108 | .. note:: 109 | When your service is traced according to the tracing percentage, the 110 | resulting trace will start at your service and will not include any upstream 111 | clients. 112 | 113 | Defaults to `0.50`. Example: 114 | 115 | .. code-block:: python 116 | 117 | 'zipkin.tracing_percent': 1.0 # Increase tracing probability to 1% 118 | 119 | 120 | zipkin.create_zipkin_attr 121 | ~~~~~~~~~~~~~~~~~~~~~~~~~ 122 | A method that takes `request` and creates a ZipkinAttrs object. This 123 | can be used to generate span_id, parent_id or other ZipkinAttrs fields 124 | based on request parameters. 125 | 126 | The method MUST take `request` as a parametr and return a ZipkinAttrs 127 | object. 128 | 129 | 130 | zipkin.is_tracing 131 | ~~~~~~~~~~~~~~~~~ 132 | A method that takes `request` and determines if the request should be 133 | traced. This can be used to determine if a request is traced based on 134 | custom application specific logic. 135 | 136 | The method MUST take `request` as a parameter and return a Boolean. 137 | 138 | 139 | zipkin.set_extra_binary_annotations 140 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 141 | A method that takes `request` and `response` objects as parameters 142 | and produces extra binary annotations. If this config is omitted, 143 | only `http.uri` and `http.uri.qs` are added as binary annotations. 144 | The return value of the callback must be a dictionary, and all keys 145 | and values must be in `str` format. Example: 146 | 147 | .. code-block:: python 148 | 149 | def set_binary_annotations(request, response): 150 | return {'view': get_view(request)} 151 | 152 | settings['zipkin.set_extra_binary_annotations'] = set_binary_annotations 153 | 154 | 155 | zipkin.request_context 156 | ~~~~~~~~~~~~~~~~~~~~~~ 157 | If it contains a valid request attribute, this specifies the stack 158 | for storing the zipin attributes. If the name is invalid or the option 159 | is missing, attributes will be stored in a thread local context. 160 | The syntax is a path in dotted notation, e.g. 'request.context.zipkin'. 161 | 162 | This option enables support for an cooperative multithreading environment 163 | (e.g. asyncio). 164 | 165 | .. code-block:: python 166 | 167 | settings['zipkin.request_context'] = 'request.context.zipkin' 168 | 169 | 170 | zipkin.post_handler_hook 171 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 172 | Callback function for processing actions after the tween functionality 173 | is executed and before the response is sent back. 174 | 175 | The actions for example could be to modify the response headers. 176 | 177 | .. code-block:: python 178 | 179 | settings['zipkin.post_handler_hook'] = post_handler_hook 180 | 181 | def post_handler_hook(request, response): 182 | do_some_work(response) 183 | 184 | 185 | zipkin.firehose_handler [EXPERIMENTAL] 186 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 187 | Callback function for "firehose tracing" mode. This will log 100% of the 188 | spans to this handler, regardless of sampling decision. 189 | 190 | This is experimental and may change or be removed at any time without warning. 191 | 192 | 193 | zipkin.encoding 194 | ~~~~~~~~~~~~~~~ 195 | py-zipkin allows you to specify the output encoding for your spans. This 196 | argument should be of type `py_zipkin.Encoding`. 197 | 198 | It defaults to `Encoding.V1_THRIFT` to keep backward compatibility. 199 | 200 | 201 | Configuring your application 202 | ---------------------------- 203 | 204 | These settings can be added at Pyramid application setup like so: 205 | 206 | .. code-block:: python 207 | 208 | def main(global_config, **settings): 209 | # ... 210 | settings['service_name'] = 'zipkin' 211 | settings['zipkin.transport_handler'] = scribe_handler 212 | settings['zipkin.stream_name'] = 'zipkin_log' 213 | settings['zipkin.blacklisted_paths'] = [r'^/foo/?'] 214 | settings['zipkin.blacklisted_routes'] = ['bar'] 215 | settings['zipkin.set_extra_binary_annotations'] = lambda req, resp: {'attr': str(req.attr)} 216 | # ...and so on with the other settings... 217 | config = Configurator(settings=settings) 218 | config.include('pyramid_zipkin') 219 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | pyramid_zipkin documentation 2 | ============================ 3 | 4 | This project acts as a `Pyramid `_ 5 | tween by using `py_zipkin `_ to facilitate creation of `zipkin `_ service spans. 6 | 7 | Features include: 8 | 9 | * Blacklisting specific route/paths from getting traced. 10 | 11 | * ``zipkin_tracing_percent`` to control the percentage of requests getting sampled. 12 | 13 | * Adds ``http.uri``, ``http.uri.qs``, and ``status_code`` binary annotations automatically for each trace. 14 | 15 | * Allows configuration of additional arbitrary binary annotations. 16 | 17 | Install 18 | ------- 19 | 20 | .. code-block:: python 21 | 22 | pip install pyramid_zipkin 23 | 24 | Usage 25 | ----- 26 | 27 | In your service's webapp, you need to include: 28 | 29 | .. code-block:: python 30 | 31 | config.include('pyramid_zipkin') 32 | 33 | Contents: 34 | 35 | .. toctree:: 36 | :maxdepth: 2 37 | 38 | configuring_zipkin 39 | pyramid_zipkin 40 | changelog 41 | 42 | Indices and tables 43 | ================== 44 | 45 | * :ref:`genindex` 46 | * :ref:`modindex` 47 | * :ref:`search` 48 | -------------------------------------------------------------------------------- /docs/source/pyramid_zipkin.rst: -------------------------------------------------------------------------------- 1 | pyramid_zipkin Package 2 | ====================== 3 | 4 | :mod:`pyramid_zipkin` Package 5 | ----------------------------- 6 | 7 | :mod:`tween` Module 8 | ------------------------- 9 | 10 | .. automodule:: pyramid_zipkin.tween 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | 16 | :mod:`request_helper` Module 17 | ---------------------------- 18 | 19 | .. automodule:: pyramid_zipkin.request_helper 20 | :members: 21 | :undoc-members: 22 | :show-inheritance: 23 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.8 3 | pretty = true 4 | show_error_codes = true 5 | show_error_context = true 6 | show_column_numbers = true 7 | disallow_untyped_defs = true 8 | disallow_incomplete_defs = true 9 | disallow_untyped_calls = true 10 | disallow_untyped_decorators = true 11 | disallow_subclassing_any = true 12 | warn_unused_ignores = true 13 | warn_unreachable = true 14 | 15 | [mypy-pyramid.*] 16 | ignore_missing_imports = true 17 | -------------------------------------------------------------------------------- /pyramid_zipkin/__init__.py: -------------------------------------------------------------------------------- 1 | from pyramid.config import Configurator 2 | from pyramid.tweens import INGRESS 3 | 4 | 5 | def includeme(config: Configurator) -> None: # pragma: no cover 6 | """ 7 | :type config: :class:`pyramid.config.Configurator` 8 | """ 9 | config.add_tween('pyramid_zipkin.tween.zipkin_tween', under=INGRESS) 10 | -------------------------------------------------------------------------------- /pyramid_zipkin/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/pyramid_zipkin/55974e359834fadd62c350e167fe47f12c6d6a8f/pyramid_zipkin/py.typed -------------------------------------------------------------------------------- /pyramid_zipkin/request_helper.py: -------------------------------------------------------------------------------- 1 | import random 2 | import re 3 | from typing import Dict 4 | from typing import Optional 5 | 6 | from py_zipkin.util import generate_random_128bit_string 7 | from py_zipkin.util import generate_random_64bit_string 8 | from py_zipkin.zipkin import ZipkinAttrs 9 | from pyramid.interfaces import IRoutesMapper 10 | from pyramid.request import Request 11 | from pyramid.response import Response 12 | 13 | from pyramid_zipkin.version import __version__ 14 | 15 | 16 | DEFAULT_REQUEST_TRACING_PERCENT = 0.5 17 | 18 | 19 | def get_trace_id(request: Request) -> str: 20 | """Gets the trace id based on a request. If not present with the request, a 21 | completely random 128-bit trace id is generated. 22 | 23 | :param: current active pyramid request 24 | :returns: the value of the 'X-B3-TraceId' header or a 128-bit hex string 25 | """ 26 | return request.headers.get('X-B3-TraceId', generate_random_128bit_string()) 27 | 28 | 29 | def should_not_sample_path(request: Request) -> bool: 30 | """Decided whether current request path should be sampled or not. This is 31 | checked previous to `should_not_sample_route` and takes precedence. 32 | 33 | :param: current active pyramid request 34 | :returns: boolean whether current request path is blacklisted. 35 | """ 36 | blacklisted_paths = request.registry.settings.get( 37 | 'zipkin.blacklisted_paths', []) 38 | # Only compile strings, since even recompiling existing 39 | # compiled regexes takes time. 40 | regexes = [ 41 | re.compile(r) if isinstance(r, str) else r 42 | for r in blacklisted_paths 43 | ] 44 | return any(r.match(request.path) for r in regexes) 45 | 46 | 47 | def should_not_sample_route(request: Request) -> bool: 48 | """Decided whether current request route should be sampled or not. 49 | 50 | :param: current active pyramid request 51 | :returns: boolean whether current request route is blacklisted. 52 | """ 53 | blacklisted_routes = request.registry.settings.get( 54 | 'zipkin.blacklisted_routes', []) 55 | 56 | if not blacklisted_routes: 57 | return False 58 | route_mapper = request.registry.queryUtility(IRoutesMapper) 59 | route_info = route_mapper(request).get('route') 60 | return (route_info and route_info.name in blacklisted_routes) 61 | 62 | 63 | def should_sample_as_per_zipkin_tracing_percent(tracing_percent: float) -> bool: 64 | """Calculate whether the request should be traced as per tracing percent. 65 | 66 | :param tracing_percent: value between 0.0 to 100.0 67 | :type tracing_percent: float 68 | :returns: boolean whether current request should be sampled. 69 | """ 70 | return (random.random() * 100) < tracing_percent 71 | 72 | 73 | def is_tracing(request: Request) -> bool: 74 | """Determine if zipkin should be tracing 75 | 1) Check whether the current request path is blacklisted. 76 | 2) If not, check whether the current request route is blacklisted. 77 | 3) If not, check if specific sampled header is present in the request. 78 | 4) If not, Use a tracing percent (default: 0.5%) to decide. 79 | 80 | :param request: pyramid request object 81 | 82 | :returns: boolean True if zipkin should be tracing 83 | """ 84 | if should_not_sample_path(request): 85 | return False 86 | elif should_not_sample_route(request): 87 | return False 88 | elif 'X-B3-Sampled' in request.headers: 89 | return request.headers.get('X-B3-Sampled') == '1' 90 | else: 91 | zipkin_tracing_percent = request.registry.settings.get( 92 | 'zipkin.tracing_percent', DEFAULT_REQUEST_TRACING_PERCENT) 93 | return should_sample_as_per_zipkin_tracing_percent( 94 | zipkin_tracing_percent) 95 | 96 | 97 | def create_zipkin_attr(request: Request) -> ZipkinAttrs: 98 | """Create ZipkinAttrs object from a request with sampled flag as True. 99 | Attaches lazy attribute `zipkin_trace_id` with request which is then used 100 | throughout the tween. 101 | 102 | Consumes custom is_tracing function to determine if the request is traced 103 | if one is set in the pyramid registry. 104 | 105 | :param request: pyramid request object 106 | :rtype: :class:`pyramid_zipkin.request_helper.ZipkinAttrs` 107 | """ 108 | settings = request.registry.settings 109 | 110 | if 'zipkin.is_tracing' in settings: 111 | is_sampled = settings['zipkin.is_tracing'](request) 112 | else: 113 | is_sampled = is_tracing(request) 114 | 115 | span_id = request.headers.get( 116 | 'X-B3-SpanId', generate_random_64bit_string()) 117 | parent_span_id = request.headers.get('X-B3-ParentSpanId', None) 118 | flags = request.headers.get('X-B3-Flags', '0') 119 | 120 | # Store zipkin_trace_id and zipkin_span_id in the request object so that 121 | # they're still available once we leave the pyramid_zipkin tween. An example 122 | # is being able to log them in the pyramid exc_logger, which runs after all 123 | # tweens have been exited. 124 | request.zipkin_trace_id = get_trace_id(request) 125 | request.zipkin_span_id = span_id 126 | 127 | return ZipkinAttrs( 128 | trace_id=request.zipkin_trace_id, 129 | span_id=span_id, 130 | parent_span_id=parent_span_id, 131 | flags=flags, 132 | is_sampled=is_sampled, 133 | ) 134 | 135 | 136 | def get_binary_annotations( 137 | request: Request, 138 | response: Response, 139 | ) -> Dict[str, Optional[str]]: 140 | """Helper method for getting all binary annotations from the request. 141 | 142 | :param request: the Pyramid request object 143 | :param response: the Pyramid response object 144 | :returns: binary annotation dict of {str: str} 145 | """ 146 | # use only @property of the request object 147 | # https://sourcegraph.com/search?q=repo:%5Egithub%5C.com/Pylons/webob$@1.8.7++file:%5Esrc/webob/request%5C.py$+@property&patternType=keyword&sm=0 148 | annotations = { 149 | 'http.uri': request.path, 150 | 'http.uri.qs': request.path_qs, 151 | 'otel.library.name': __name__.split('.')[0], 152 | 'otel.library.version': __version__, 153 | } 154 | 155 | if request.client_addr: 156 | annotations['client.address'] = request.client_addr 157 | 158 | if request.matched_route: 159 | annotations['http.route'] = request.matched_route.pattern 160 | 161 | # update attributes from request.environ object 162 | # https://sourcegraph.com/github.com/Pylons/webob@1.8.7/-/blob/docs/index.txt?L63-68 163 | request_env = request.environ 164 | _update_annotations_from_request_environ(request_env, annotations) 165 | 166 | if response: 167 | status_code = response.status_code 168 | if isinstance(status_code, int): 169 | annotations['http.response.status_code'] = str(status_code) 170 | annotations['response_status_code'] = str(status_code) 171 | if 100 <= status_code < 200: 172 | annotations['otel.status_code'] = 'Unset' 173 | elif 200 <= status_code < 300: 174 | annotations['otel.status_code'] = 'Ok' 175 | elif 300 <= status_code < 500: 176 | annotations['otel.status_code'] = 'Unset' 177 | else: 178 | annotations['otel.status_code'] = 'Error' 179 | 180 | else: 181 | annotations['otel.status_code'] = 'Error' 182 | annotations['otel.status_description'] = ( 183 | f'Non-integer HTTP status code: {repr(status_code)}' 184 | ) 185 | 186 | settings = request.registry.settings 187 | if 'zipkin.set_extra_binary_annotations' in settings: 188 | annotations.update( 189 | settings['zipkin.set_extra_binary_annotations'](request, response) 190 | ) 191 | return annotations 192 | 193 | 194 | def _update_annotations_from_request_environ( 195 | environ: Dict, 196 | annotations: Dict[str, Optional[str]] 197 | ) -> None: 198 | method = environ.get('REQUEST_METHOD', '').strip() 199 | if method: 200 | annotations['http.request.method'] = method 201 | 202 | flavor = environ.get('SERVER_PROTOCOL', '') 203 | if flavor: 204 | annotations['network.protocol.version'] = flavor 205 | 206 | path = environ.get('PATH_INFO') 207 | if path: 208 | annotations['url.path'] = path 209 | 210 | host_name = environ.get('SERVER_NAME') 211 | host_port = environ.get('SERVER_PORT') 212 | 213 | if host_name: 214 | annotations['server.address'] = host_name 215 | 216 | if host_port: 217 | annotations['server.port'] = str(host_port) 218 | 219 | url_scheme = environ.get('wsgi.url_scheme') 220 | if url_scheme: 221 | annotations['url.scheme'] = url_scheme 222 | 223 | user_agent = environ.get('HTTP_USER_AGENT') 224 | if user_agent: 225 | annotations['user_agent.original'] = user_agent 226 | 227 | query_string = environ.get('QUERY_STRING') 228 | if query_string: 229 | annotations['url.query'] = query_string 230 | -------------------------------------------------------------------------------- /pyramid_zipkin/tween.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import traceback 3 | import warnings 4 | from collections import namedtuple 5 | from typing import Any 6 | from typing import Callable 7 | 8 | from py_zipkin import Encoding 9 | from py_zipkin import Kind 10 | from py_zipkin.exception import ZipkinError 11 | from py_zipkin.storage import get_default_tracer 12 | from py_zipkin.transport import BaseTransportHandler 13 | from pyramid.registry import Registry 14 | from pyramid.request import Request 15 | from pyramid.response import Response 16 | 17 | from pyramid_zipkin.request_helper import create_zipkin_attr 18 | from pyramid_zipkin.request_helper import get_binary_annotations 19 | from pyramid_zipkin.request_helper import should_not_sample_path 20 | from pyramid_zipkin.request_helper import should_not_sample_route 21 | 22 | 23 | def _getattr_path(obj: Any, path: str) -> Any: 24 | """ 25 | getattr for a dot separated path 26 | 27 | If an AttributeError is raised, it will return None. 28 | """ 29 | if not path: 30 | return None 31 | 32 | for attr in path.split('.'): 33 | obj = getattr(obj, attr, None) 34 | return obj 35 | 36 | 37 | _ZipkinSettings = namedtuple('_ZipkinSettings', [ 38 | 'zipkin_attrs', 39 | 'transport_handler', 40 | 'service_name', 41 | 'span_name', 42 | 'add_logging_annotation', 43 | 'report_root_timestamp', 44 | 'host', 45 | 'port', 46 | 'context_stack', 47 | 'firehose_handler', 48 | 'post_handler_hook', 49 | 'max_span_batch_size', 50 | 'use_pattern_as_span_name', 51 | 'encoding', 52 | ]) 53 | 54 | 55 | def _get_settings_from_request(request: Request) -> _ZipkinSettings: 56 | """Extracts Zipkin attributes and configuration from request attributes. 57 | See the `zipkin_span` context in py-zipkin for more detaied information on 58 | all the settings. 59 | 60 | Here are the supported Pyramid registry settings: 61 | 62 | zipkin.create_zipkin_attr: allows the service to override the creation of 63 | Zipkin attributes. For example, if you want to deterministically 64 | calculate trace ID from some service-specific attributes. 65 | zipkin.transport_handler: how py-zipkin will log the spans it generates. 66 | zipkin.stream_name: an additional parameter to be used as the first arg 67 | to the transport_handler function. A good example is a Kafka topic. 68 | zipkin.add_logging_annotation: if true, the outermost span in this service 69 | will have an annotation set when py-zipkin begins its logging. 70 | zipkin.report_root_timestamp: if true, the outermost span in this service 71 | will set its timestamp and duration attributes. Use this only if this 72 | service is not going to have a corresponding client span. See 73 | https://github.com/Yelp/pyramid_zipkin/issues/68 74 | zipkin.firehose_handler: [EXPERIMENTAL] this enables "firehose tracing", 75 | which will log 100% of the spans to this handler, regardless of 76 | sampling decision. This is experimental and may change or be removed 77 | at any time without warning. 78 | zipkin.use_pattern_as_span_name: if true, we'll use the pyramid route pattern 79 | as span name. If false (default) we'll keep using the raw url path. 80 | """ 81 | settings = request.registry.settings 82 | 83 | # Creates zipkin_attrs and attaches a zipkin_trace_id attr to the request 84 | if 'zipkin.create_zipkin_attr' in settings: 85 | zipkin_attrs = settings['zipkin.create_zipkin_attr'](request) 86 | else: 87 | zipkin_attrs = create_zipkin_attr(request) 88 | 89 | if 'zipkin.transport_handler' in settings: 90 | transport_handler = settings['zipkin.transport_handler'] 91 | if not isinstance(transport_handler, BaseTransportHandler): 92 | warnings.warn( 93 | 'Using a function as transport_handler is deprecated. ' 94 | 'Please extend py_zipkin.transport.BaseTransportHandler', 95 | DeprecationWarning, 96 | ) 97 | stream_name = settings.get('zipkin.stream_name', 'zipkin') 98 | transport_handler = functools.partial(transport_handler, stream_name) 99 | else: 100 | raise ZipkinError( 101 | "`zipkin.transport_handler` is a required config property, which" 102 | " is missing. Have a look at py_zipkin's docs for how to implement" 103 | " it: https://github.com/Yelp/py_zipkin#transport" 104 | ) 105 | 106 | context_stack = _getattr_path(request, settings.get('zipkin.request_context')) 107 | 108 | service_name = settings.get('service_name', 'unknown') 109 | span_name = f'{request.method} {request.path}' 110 | add_logging_annotation = settings.get( 111 | 'zipkin.add_logging_annotation', 112 | False, 113 | ) 114 | 115 | # If the incoming request doesn't have Zipkin headers, this request is 116 | # assumed to be the root span of a trace. There's also a configuration 117 | # override to allow services to write their own logic for reporting 118 | # timestamp/duration. 119 | if 'zipkin.report_root_timestamp' in settings: 120 | report_root_timestamp = settings['zipkin.report_root_timestamp'] 121 | else: 122 | report_root_timestamp = 'X-B3-TraceId' not in request.headers 123 | zipkin_host = settings.get('zipkin.host') 124 | zipkin_port = settings.get('zipkin.port', request.server_port) 125 | firehose_handler = settings.get('zipkin.firehose_handler') 126 | post_handler_hook = settings.get('zipkin.post_handler_hook') 127 | max_span_batch_size = settings.get('zipkin.max_span_batch_size') 128 | use_pattern_as_span_name = bool( 129 | settings.get('zipkin.use_pattern_as_span_name', False), 130 | ) 131 | encoding = settings.get('zipkin.encoding', Encoding.V2_JSON) 132 | return _ZipkinSettings( 133 | zipkin_attrs, 134 | transport_handler, 135 | service_name, 136 | span_name, 137 | add_logging_annotation, 138 | report_root_timestamp, 139 | zipkin_host, 140 | zipkin_port, 141 | context_stack, 142 | firehose_handler, 143 | post_handler_hook, 144 | max_span_batch_size, 145 | use_pattern_as_span_name, 146 | encoding=encoding, 147 | ) 148 | 149 | 150 | Handler = Callable[[Request], Response] 151 | 152 | 153 | def zipkin_tween(handler: Handler, registry: Registry) -> Handler: 154 | """ 155 | Factory for pyramid tween to handle zipkin server logging. Note that even 156 | if the request isn't sampled, Zipkin attributes are generated and pushed 157 | into threadlocal storage, so `create_http_headers_for_new_span` and 158 | `zipkin_span` will have access to the proper Zipkin state. 159 | 160 | Consumes custom create_zipkin_attr function if one is set in the pyramid 161 | registry. 162 | 163 | :param handler: pyramid request handler 164 | :param registry: pyramid app registry 165 | 166 | :returns: pyramid tween 167 | """ 168 | def tween(request: Request) -> Response: 169 | zipkin_settings = _get_settings_from_request(request) 170 | tracer = get_default_tracer() 171 | 172 | tween_kwargs = dict( 173 | service_name=zipkin_settings.service_name, 174 | span_name=zipkin_settings.span_name, 175 | zipkin_attrs=zipkin_settings.zipkin_attrs, 176 | transport_handler=zipkin_settings.transport_handler, 177 | host=zipkin_settings.host, 178 | port=zipkin_settings.port, 179 | add_logging_annotation=zipkin_settings.add_logging_annotation, 180 | report_root_timestamp=zipkin_settings.report_root_timestamp, 181 | context_stack=zipkin_settings.context_stack, 182 | max_span_batch_size=zipkin_settings.max_span_batch_size, 183 | encoding=zipkin_settings.encoding, 184 | kind=Kind.SERVER, 185 | ) 186 | 187 | # Only set the firehose_handler if it's defined and only if the current 188 | # request is not blacklisted. This prevents py_zipkin from emitting 189 | # firehose spans for blacklisted paths like /status 190 | if zipkin_settings.firehose_handler is not None and \ 191 | not should_not_sample_path(request) and \ 192 | not should_not_sample_route(request): 193 | tween_kwargs['firehose_handler'] = zipkin_settings.firehose_handler 194 | 195 | with tracer.zipkin_span(**tween_kwargs) as zipkin_context: 196 | response = None 197 | try: 198 | response = handler(request) 199 | except Exception as e: 200 | exception_type = type(e).__name__ 201 | exception_stacktrace = traceback.format_exc() 202 | zipkin_context.update_binary_annotations({ 203 | 'error.type': exception_type, 204 | 'response_status_code': '500', 205 | 'http.response.status_code': '500', 206 | 'exception.stacktrace': exception_stacktrace, 207 | }) 208 | zipkin_context.add_annotation(exception_type) 209 | raise e 210 | finally: 211 | if zipkin_settings.use_pattern_as_span_name \ 212 | and request.matched_route: 213 | zipkin_context.override_span_name('{} {}'.format( 214 | request.method, 215 | request.matched_route.pattern, 216 | )) 217 | zipkin_context.update_binary_annotations( 218 | get_binary_annotations(request, response), 219 | ) 220 | 221 | if zipkin_settings.post_handler_hook: 222 | zipkin_settings.post_handler_hook( 223 | request, 224 | response, 225 | zipkin_context 226 | ) 227 | 228 | return response 229 | 230 | return tween 231 | -------------------------------------------------------------------------------- /pyramid_zipkin/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '3.0.1' 2 | -------------------------------------------------------------------------------- /pyramid_zipkin/zipkin.py: -------------------------------------------------------------------------------- 1 | from py_zipkin.zipkin import create_http_headers_for_new_span # pragma: no cover 2 | 3 | 4 | # Backwards compatibility for places where pyramid_zipkin is unpinned 5 | create_headers_for_new_span = create_http_headers_for_new_span # pragma: no cover 6 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | coverage 2 | mypy 3 | ordereddict 4 | pytest 5 | tox 6 | webtest 7 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [pep8] 2 | ignore = E265,E309,E501 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | from setuptools import find_packages 3 | from setuptools import setup 4 | 5 | with open('pyramid_zipkin/version.py') as f: 6 | exec(f.read()) 7 | 8 | version = locals()['__version__'] 9 | 10 | setup( 11 | name='pyramid_zipkin', 12 | version=version, 13 | provides=["pyramid_zipkin"], 14 | author='Yelp, Inc.', 15 | author_email='opensource+pyramid-zipkin@yelp.com', 16 | license='Copyright Yelp 2018', 17 | url="https://github.com/Yelp/pyramid_zipkin", 18 | description='Zipkin instrumentation for the Pyramid framework.', 19 | packages=find_packages(exclude=('tests*',)), 20 | package_data={ 21 | '': ['*.thrift'], 22 | 'pyramid_zipkin': ['py.typed'], 23 | }, 24 | install_requires=[ 25 | 'py_zipkin >= 0.18.1', 26 | 'pyramid', 27 | ], 28 | classifiers=[ 29 | "Development Status :: 5 - Production/Stable", 30 | "Intended Audience :: Developers", 31 | "Topic :: Software Development :: Libraries :: Python Modules", 32 | "License :: OSI Approved :: Apache Software License", 33 | "Operating System :: OS Independent", 34 | "Programming Language :: Python :: 3.8", 35 | ], 36 | python_requires=">=3.8", 37 | ) 38 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/pyramid_zipkin/55974e359834fadd62c350e167fe47f12c6d6a8f/tests/__init__.py -------------------------------------------------------------------------------- /tests/acceptance/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yelp/pyramid_zipkin/55974e359834fadd62c350e167fe47f12c6d6a8f/tests/acceptance/__init__.py -------------------------------------------------------------------------------- /tests/acceptance/app/__init__.py: -------------------------------------------------------------------------------- 1 | from py_zipkin import Encoding 2 | from py_zipkin.zipkin import create_http_headers_for_new_span 3 | from py_zipkin.zipkin import zipkin_span 4 | from pyramid.config import Configurator 5 | from pyramid.response import Response 6 | from pyramid.tweens import EXCVIEW 7 | from pyramid.view import view_config 8 | 9 | 10 | @view_config(route_name='sample_route', renderer='json') 11 | def sample(dummy_request): 12 | return {} 13 | 14 | 15 | @view_config(route_name='sample_route_v2', renderer='json') 16 | def sample_v2(dummy_request): 17 | with zipkin_span( 18 | service_name='root_service', 19 | span_name='v2', 20 | annotations={'foo': 1664500002.0, 'bar': 1664500001.0}, 21 | binary_annotations={'ping': 'pong'}, 22 | ): 23 | return {} 24 | 25 | 26 | @view_config(route_name='sample_route_v2_client', renderer='json') 27 | def sample_v2_client(dummy_request): 28 | with zipkin_span( 29 | service_name='foo_service', 30 | span_name='v2_client', 31 | annotations={'foo_client': 1664500002.0}, 32 | ): 33 | pass 34 | with zipkin_span( 35 | service_name='bar_service', 36 | span_name='v2_client', 37 | annotations={'bar_client': 1664500001.0}, 38 | ): 39 | pass 40 | return {} 41 | 42 | 43 | @view_config(route_name='span_context', renderer='json') 44 | def span_context(dummy_request): 45 | # Creates a new span, a child of the server span 46 | with zipkin_span( 47 | service_name='child', 48 | span_name='get', 49 | annotations={'child_annotation': 1664500000.0}, 50 | binary_annotations={'foo': 'bar', 'child': 'true'}, 51 | ): 52 | with zipkin_span( 53 | service_name='grandchild', 54 | span_name='put', 55 | annotations={'grandchild_annotation': 1664500000.0}, 56 | binary_annotations={'grandchild': 'true'}, 57 | ): 58 | return {} 59 | 60 | 61 | @view_config(route_name='decorator_context', renderer='json') 62 | def decorator_context(dummy_request): 63 | 64 | @zipkin_span( 65 | service_name='my_service', 66 | span_name='my_span', 67 | binary_annotations={'a': '1'}, 68 | ) 69 | def some_function(a, b): 70 | return str(a + b) 71 | 72 | return {'result': some_function(1, 2)} 73 | 74 | 75 | @view_config(route_name='pattern_route', renderer='json') 76 | def pattern_route(dummy_request): 77 | return {'result': dummy_request.matchdict['petId']} 78 | 79 | 80 | @view_config(route_name='sample_route_child_span', renderer='json') 81 | def sample_child_span(dummy_request): 82 | return create_http_headers_for_new_span() 83 | 84 | 85 | @view_config(route_name='server_error', renderer='json') 86 | def server_error(dummy_request): 87 | response = Response('Server Error!') 88 | response.status_int = 500 89 | return response 90 | 91 | 92 | @view_config(route_name='client_error', renderer='json') 93 | def client_error(dummy_request): 94 | response = Response('Client Error!') 95 | response.status_int = 400 96 | return response 97 | 98 | 99 | @view_config(route_name='redirect', renderer='json') 100 | def redirect(dummy_request): 101 | response = Response('Redirectional') 102 | response.status_int = 302 103 | return response 104 | 105 | 106 | @view_config(route_name='information_route', renderer='json') 107 | def information_route(dummy_request): 108 | response = Response('Informational') 109 | response.status_int = 199 110 | return response 111 | 112 | 113 | def main(global_config, **settings): 114 | """ Very basic pyramid app """ 115 | settings['service_name'] = 'acceptance_service' 116 | settings['zipkin.transport_handler'] = lambda x, y: None 117 | settings['zipkin.encoding'] = Encoding.V2_JSON 118 | 119 | config = Configurator(settings=settings) 120 | 121 | config.add_route('sample_route', '/sample') 122 | config.add_route('sample_route_v2', '/sample_v2') 123 | config.add_route('sample_route_v2_client', '/sample_v2_client') 124 | config.add_route('sample_route_child_span', '/sample_child_span') 125 | config.add_route('span_context', '/span_context') 126 | config.add_route('decorator_context', '/decorator_context') 127 | config.add_route('pattern_route', '/pet/{petId}') 128 | 129 | config.add_route('server_error', '/server_error') 130 | config.add_route('client_error', '/client_error') 131 | config.add_route('information_route', '/information_route') 132 | config.add_route('redirect', '/redirect') 133 | 134 | config.scan() 135 | 136 | config.add_tween('pyramid_zipkin.tween.zipkin_tween', over=EXCVIEW) 137 | 138 | return config.make_wsgi_app() 139 | -------------------------------------------------------------------------------- /tests/acceptance/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | 5 | from pyramid_zipkin.version import __version__ 6 | 7 | 8 | @pytest.fixture 9 | def settings(): 10 | return { 11 | 'zipkin.tracing_percent': 100, 12 | } 13 | 14 | 15 | @pytest.fixture 16 | def get_span(): 17 | return { 18 | 'id': '17133d482ba4f605', 19 | 'tags': { 20 | 'http.uri': '/sample', 21 | 'http.uri.qs': '/sample', 22 | 'http.route': '/sample', 23 | 'url.path': '/sample', 24 | 'url.scheme': 'http', 25 | 'network.protocol.version': 'HTTP/1.0', 26 | 'server.address': 'localhost', 27 | 'server.port': '80', 28 | 'response_status_code': '200', 29 | 'http.response.status_code': '200', 30 | 'http.request.method': 'GET', 31 | 'otel.status_code': 'Ok', 32 | 'otel.library.version': __version__, 33 | 'otel.library.name': 'pyramid_zipkin', 34 | }, 35 | 'name': 'GET /sample', 36 | 'traceId': '66ec982fcfba8bf3b32d71d76e4a16a3', 37 | 'localEndpoint': { 38 | 'ipv4': mock.ANY, 39 | 'port': 80, 40 | 'serviceName': 'acceptance_service', 41 | }, 42 | 'kind': 'SERVER', 43 | 'timestamp': mock.ANY, 44 | 'duration': mock.ANY, 45 | } 46 | 47 | 48 | @pytest.fixture 49 | def mock_generate_random_128bit_string(): 50 | with mock.patch( 51 | 'pyramid_zipkin.request_helper.generate_random_128bit_string', 52 | return_value='66ec982fcfba8bf3b32d71d76e4a16a3', 53 | ) as m: 54 | yield m 55 | 56 | 57 | @pytest.fixture 58 | def mock_generate_random_64bit_string(): 59 | with mock.patch( 60 | 'pyramid_zipkin.request_helper.generate_random_64bit_string', 61 | return_value='17133d482ba4f605', 62 | ) as m: 63 | yield m 64 | -------------------------------------------------------------------------------- /tests/acceptance/server_span_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | from unittest import mock 4 | 5 | import pytest 6 | from py_zipkin.exception import ZipkinError 7 | from py_zipkin.zipkin import ZipkinAttrs 8 | from webtest import TestApp as WebTestApp 9 | 10 | from .app import main 11 | from pyramid_zipkin.version import __version__ 12 | from tests.acceptance.test_helper import generate_app_main 13 | 14 | 15 | @pytest.mark.parametrize(['set_post_handler_hook', 'called'], [ 16 | (False, 0), 17 | (True, 1), 18 | ]) 19 | def test_sample_server_span_with_100_percent_tracing( 20 | get_span, 21 | mock_generate_random_128bit_string, 22 | mock_generate_random_64bit_string, 23 | set_post_handler_hook, 24 | called, 25 | ): 26 | settings = {'zipkin.tracing_percent': 100} 27 | 28 | mock_post_handler_hook = mock.Mock() 29 | if set_post_handler_hook: 30 | settings['zipkin.post_handler_hook'] = mock_post_handler_hook 31 | 32 | app_main, transport, _ = generate_app_main(settings) 33 | 34 | old_time = time.time() * 1000000 35 | 36 | WebTestApp(app_main).get('/sample', status=200) 37 | 38 | assert mock_post_handler_hook.call_count == called 39 | assert len(transport.output) == 1 40 | spans = json.loads(transport.output[0]) 41 | assert len(spans) == 1 42 | 43 | span = spans[0] 44 | assert span['id'] == '17133d482ba4f605' 45 | assert span['kind'] == 'SERVER' 46 | assert span['timestamp'] > old_time 47 | assert span['duration'] > 0 48 | assert 'shared' not in span 49 | 50 | assert span == get_span 51 | 52 | 53 | def test_upstream_zipkin_headers_sampled(): 54 | settings = {} 55 | app_main, transport, _ = generate_app_main(settings) 56 | 57 | trace_hex = 'aaaaaaaaaaaaaaaa' 58 | span_hex = 'bbbbbbbbbbbbbbbb' 59 | parent_hex = 'cccccccccccccccc' 60 | 61 | WebTestApp(app_main).get( 62 | '/sample', 63 | status=200, 64 | headers={ 65 | 'X-B3-TraceId': trace_hex, 66 | 'X-B3-SpanId': span_hex, 67 | 'X-B3-ParentSpanId': parent_hex, 68 | 'X-B3-Flags': '0', 69 | 'X-B3-Sampled': '1', 70 | }, 71 | ) 72 | 73 | spans = json.loads(transport.output[0]) 74 | assert len(spans) == 1 75 | 76 | span = spans[0] 77 | assert span['traceId'] == trace_hex 78 | assert span['id'] == span_hex 79 | assert span['parentId'] == parent_hex 80 | assert span['kind'] == 'SERVER' 81 | assert span['shared'] is True 82 | 83 | 84 | @pytest.mark.parametrize(['set_post_handler_hook', 'called'], [ 85 | (False, 0), 86 | (True, 1), 87 | ]) 88 | def test_unsampled_request_has_no_span( 89 | set_post_handler_hook, 90 | called, 91 | ): 92 | settings = {'zipkin.tracing_percent': 0} 93 | 94 | mock_post_handler_hook = mock.Mock() 95 | if set_post_handler_hook: 96 | settings['zipkin.post_handler_hook'] = mock_post_handler_hook 97 | 98 | app_main, transport, _ = generate_app_main(settings) 99 | 100 | WebTestApp(app_main).get('/sample', status=200) 101 | 102 | assert len(transport.output) == 0 103 | assert mock_post_handler_hook.call_count == called 104 | 105 | 106 | def test_blacklisted_route_has_no_span(): 107 | settings = { 108 | 'zipkin.tracing_percent': 100, 109 | 'zipkin.blacklisted_routes': ['sample_route'], 110 | } 111 | app_main, transport, firehose = generate_app_main(settings, firehose=True) 112 | 113 | WebTestApp(app_main).get('/sample', status=200) 114 | 115 | assert len(transport.output) == 0 116 | assert len(firehose.output) == 0 117 | 118 | 119 | def test_blacklisted_path_has_no_span(): 120 | settings = { 121 | 'zipkin.tracing_percent': 100, 122 | 'zipkin.blacklisted_paths': [r'^/sample'], 123 | } 124 | app_main, transport, firehose = generate_app_main(settings, firehose=True) 125 | 126 | WebTestApp(app_main).get('/sample', status=200) 127 | 128 | assert len(transport.output) == 0 129 | assert len(firehose.output) == 0 130 | 131 | 132 | def test_no_transport_handler_throws_error(): 133 | app_main = main({}) 134 | del app_main.registry.settings['zipkin.transport_handler'] 135 | assert 'zipkin.transport_handler' not in app_main.registry.settings 136 | 137 | with pytest.raises(ZipkinError): 138 | WebTestApp(app_main).get('/sample', status=200) 139 | 140 | 141 | def test_binary_annotations(): 142 | def set_extra_binary_annotations(dummy_request, response): 143 | return {'other': dummy_request.registry.settings['other_attr']} 144 | 145 | settings = { 146 | 'zipkin.tracing_percent': 100, 147 | 'zipkin.set_extra_binary_annotations': set_extra_binary_annotations, 148 | 'other_attr': '42', 149 | } 150 | app_main, transport, _ = generate_app_main(settings) 151 | 152 | WebTestApp(app_main).get('/pet/123?test=1', status=200) 153 | 154 | assert len(transport.output) == 1 155 | spans = json.loads(transport.output[0]) 156 | assert len(spans) == 1 157 | 158 | span = spans[0] 159 | assert span['tags'] == { 160 | 'http.uri': '/pet/123', 161 | 'http.uri.qs': '/pet/123?test=1', 162 | 'http.route': '/pet/{petId}', 163 | 'response_status_code': '200', 164 | 'http.response.status_code': '200', 165 | 'http.request.method': 'GET', 166 | 'otel.status_code': 'Ok', 167 | 'network.protocol.version': 'HTTP/1.0', 168 | 'server.address': 'localhost', 169 | 'server.port': '80', 170 | 'url.path': '/pet/123', 171 | 'url.scheme': 'http', 172 | 'url.query': 'test=1', 173 | 'other': '42', 174 | 'otel.library.version': __version__, 175 | 'otel.library.name': mock.ANY, 176 | } 177 | 178 | 179 | def test_binary_annotations_404(): 180 | settings = {'zipkin.tracing_percent': 100} 181 | app_main, transport, _ = generate_app_main(settings) 182 | 183 | WebTestApp(app_main).get('/abcd?test=1', status=404) 184 | 185 | assert len(transport.output) == 1 186 | spans = json.loads(transport.output[0]) 187 | assert len(spans) == 1 188 | 189 | span = spans[0] 190 | assert span['tags'] == { 191 | 'http.uri': '/abcd', 192 | 'http.uri.qs': '/abcd?test=1', 193 | 'response_status_code': '404', 194 | 'http.request.method': 'GET', 195 | 'http.response.status_code': '404', 196 | 'otel.status_code': 'Unset', 197 | 'network.protocol.version': 'HTTP/1.0', 198 | 'server.address': 'localhost', 199 | 'server.port': '80', 200 | 'url.path': '/abcd', 201 | 'url.query': 'test=1', 202 | 'url.scheme': 'http', 203 | 'otel.library.version': __version__, 204 | 'otel.library.name': 'pyramid_zipkin', 205 | } 206 | 207 | 208 | def test_information_route(): 209 | settings = {'zipkin.tracing_percent': 100} 210 | app_main, transport, _ = generate_app_main(settings) 211 | 212 | WebTestApp(app_main).get('/information_route', status=199) 213 | 214 | assert len(transport.output) == 1 215 | spans = json.loads(transport.output[0]) 216 | assert len(spans) == 1 217 | 218 | span = spans[0] 219 | assert span['tags'] == { 220 | 'http.uri': '/information_route', 221 | 'http.uri.qs': '/information_route', 222 | 'http.route': '/information_route', 223 | 'response_status_code': '199', 224 | 'http.request.method': 'GET', 225 | 'http.response.status_code': '199', 226 | 'otel.status_code': 'Unset', 227 | 'network.protocol.version': 'HTTP/1.0', 228 | 'server.address': 'localhost', 229 | 'server.port': '80', 230 | 'url.path': '/information_route', 231 | 'url.scheme': 'http', 232 | 'otel.library.version': __version__, 233 | 'otel.library.name': 'pyramid_zipkin', 234 | } 235 | 236 | 237 | def test_redirect(): 238 | settings = {'zipkin.tracing_percent': 100} 239 | app_main, transport, _ = generate_app_main(settings) 240 | 241 | WebTestApp(app_main).get('/redirect', status=302) 242 | 243 | assert len(transport.output) == 1 244 | spans = json.loads(transport.output[0]) 245 | assert len(spans) == 1 246 | 247 | span = spans[0] 248 | assert span['tags'] == { 249 | 'http.uri': '/redirect', 250 | 'http.uri.qs': '/redirect', 251 | 'http.route': '/redirect', 252 | 'response_status_code': '302', 253 | 'http.request.method': 'GET', 254 | 'http.response.status_code': '302', 255 | 'otel.status_code': 'Unset', 256 | 'network.protocol.version': 'HTTP/1.0', 257 | 'server.address': 'localhost', 258 | 'server.port': '80', 259 | 'url.path': '/redirect', 260 | 'url.scheme': 'http', 261 | 'otel.library.version': __version__, 262 | 'otel.library.name': 'pyramid_zipkin', 263 | } 264 | 265 | 266 | def test_binary_annotations_500(): 267 | settings = {'zipkin.tracing_percent': 100} 268 | app_main, transport, _ = generate_app_main(settings) 269 | 270 | WebTestApp(app_main).get('/server_error', status=500) 271 | 272 | assert len(transport.output) == 1 273 | spans = json.loads(transport.output[0]) 274 | assert len(spans) == 1 275 | 276 | span = spans[0] 277 | assert span['tags'] == { 278 | 'http.uri': '/server_error', 279 | 'http.uri.qs': '/server_error', 280 | 'http.route': '/server_error', 281 | 'response_status_code': '500', 282 | 'http.request.method': 'GET', 283 | 'http.response.status_code': '500', 284 | 'otel.status_code': 'Error', 285 | 'network.protocol.version': 'HTTP/1.0', 286 | 'server.address': 'localhost', 287 | 'server.port': '80', 288 | 'url.path': '/server_error', 289 | 'url.scheme': 'http', 290 | 'otel.library.version': __version__, 291 | 'otel.library.name': 'pyramid_zipkin', 292 | } 293 | 294 | 295 | def test_custom_create_zipkin_attr(): 296 | custom_create_zipkin_attr = mock.Mock(return_value=ZipkinAttrs( 297 | trace_id='1234', 298 | span_id='1234', 299 | parent_span_id='5678', 300 | flags=0, 301 | is_sampled=True, 302 | )) 303 | 304 | settings = { 305 | 'zipkin.create_zipkin_attr': custom_create_zipkin_attr 306 | } 307 | app_main, transport, _ = generate_app_main(settings) 308 | 309 | WebTestApp(app_main).get('/sample?test=1', status=200) 310 | 311 | assert custom_create_zipkin_attr.called 312 | 313 | 314 | def test_report_root_timestamp(): 315 | settings = { 316 | 'zipkin.report_root_timestamp': True, 317 | 'zipkin.tracing_percent': 100.0, 318 | } 319 | app_main, transport, _ = generate_app_main(settings) 320 | 321 | old_time = time.time() * 1000000 322 | 323 | WebTestApp(app_main).get('/sample', status=200) 324 | 325 | assert len(transport.output) == 1 326 | spans = json.loads(transport.output[0]) 327 | assert len(spans) == 1 328 | 329 | span = spans[0] 330 | # report_root_timestamp means there's no client span with the 331 | # same id, so the 'shared' flag should not be set. 332 | assert 'shared' not in span 333 | assert span['timestamp'] > old_time 334 | assert span['duration'] > 0 335 | 336 | 337 | def test_host_and_port_in_span(): 338 | settings = { 339 | 'zipkin.tracing_percent': 100, 340 | 'zipkin.host': '1.2.2.1', 341 | 'zipkin.port': 1231, 342 | } 343 | app_main, transport, _ = generate_app_main(settings) 344 | 345 | WebTestApp(app_main).get('/sample?test=1', status=200) 346 | 347 | assert len(transport.output) == 1 348 | spans = json.loads(transport.output[0]) 349 | assert len(spans) == 1 350 | 351 | span = spans[0] 352 | assert span['localEndpoint'] == { 353 | 'ipv4': '1.2.2.1', 354 | 'port': 1231, 355 | 'serviceName': 'acceptance_service', 356 | } 357 | 358 | 359 | def test_sample_server_span_with_firehose_tracing( 360 | get_span, 361 | mock_generate_random_128bit_string, 362 | mock_generate_random_64bit_string, 363 | ): 364 | settings = {'zipkin.tracing_percent': 0} 365 | app_main, normal_transport, firehose_transport = generate_app_main( 366 | settings, 367 | firehose=True, 368 | ) 369 | 370 | old_time = time.time() * 1000000 371 | 372 | WebTestApp(app_main).get('/sample', status=200) 373 | 374 | assert len(normal_transport.output) == 0 375 | assert len(firehose_transport.output) == 1 376 | spans = json.loads(firehose_transport.output[0]) 377 | assert len(spans) == 1 378 | 379 | span = spans[0] 380 | assert span['timestamp'] > old_time 381 | assert span['duration'] > 0 382 | assert span == get_span 383 | 384 | 385 | def test_max_span_batch_size(): 386 | settings = { 387 | 'zipkin.tracing_percent': 0, 388 | 'zipkin.max_span_batch_size': 1, 389 | } 390 | app_main, normal_transport, firehose_transport = generate_app_main( 391 | settings, 392 | firehose=True, 393 | ) 394 | 395 | WebTestApp(app_main).get('/decorator_context', status=200) 396 | 397 | # Assert the expected number of batches for two spans 398 | assert len(normal_transport.output) == 0 399 | assert len(firehose_transport.output) == 2 400 | 401 | # Assert proper hierarchy 402 | batch_one = json.loads(firehose_transport.output[0]) 403 | assert len(batch_one) == 1 404 | child_span = batch_one[0] 405 | 406 | batch_two = json.loads(firehose_transport.output[1]) 407 | assert len(batch_two) == 1 408 | server_span = batch_two[0] 409 | 410 | assert child_span['parentId'] == server_span['id'] 411 | assert child_span['name'] == 'my_span' 412 | 413 | 414 | def test_use_pattern_as_span_name(): 415 | settings = { 416 | 'zipkin.tracing_percent': 100, 417 | 'other_attr': '42', 418 | 'zipkin.use_pattern_as_span_name': True, 419 | } 420 | app_main, transport, _ = generate_app_main(settings) 421 | 422 | WebTestApp(app_main).get('/pet/123?test=1', status=200) 423 | 424 | assert len(transport.output) == 1 425 | spans = json.loads(transport.output[0]) 426 | 427 | assert len(spans) == 1 428 | span = spans[0] 429 | # Check that the span name is the pyramid pattern and not the raw url 430 | assert span['name'] == 'GET /pet/{petId}' 431 | 432 | 433 | def test_defaults_at_using_raw_url_path(): 434 | settings = { 435 | 'zipkin.tracing_percent': 100, 436 | 'other_attr': '42', 437 | } 438 | app_main, transport, _ = generate_app_main(settings) 439 | 440 | WebTestApp(app_main).get('/pet/123?test=1', status=200) 441 | 442 | assert len(transport.output) == 1 443 | spans = json.loads(transport.output[0]) 444 | 445 | assert len(spans) == 1 446 | span = spans[0] 447 | # Check that the span name is the raw url by default 448 | assert span['name'] == 'GET /pet/123' 449 | 450 | 451 | def test_sample_server_ipv6(get_span): 452 | # Assert that pyramid_zipkin and py_zipkin correctly handle ipv6 addresses. 453 | settings = {'zipkin.tracing_percent': 100} 454 | app_main, transport, _ = generate_app_main(settings) 455 | 456 | # py_zipkin uses `socket.gethostbyname` to get the current host ip if it's not 457 | # set in settings. 458 | with mock.patch( 459 | 'socket.gethostbyname', 460 | return_value='2001:db8:85a3::8a2e:370:7334', 461 | autospec=True, 462 | ): 463 | WebTestApp(app_main).get('/sample', status=200) 464 | 465 | assert len(transport.output) == 1 466 | spans = json.loads(transport.output[0]) 467 | 468 | assert len(spans) == 1 469 | span = spans[0] 470 | # Check that the span name is the raw url by default 471 | assert span['localEndpoint'] == { 472 | 'serviceName': 'acceptance_service', 473 | 'port': 80, 474 | 'ipv6': '2001:db8:85a3::8a2e:370:7334', 475 | } 476 | -------------------------------------------------------------------------------- /tests/acceptance/span_context_test.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest import mock 3 | 4 | from webtest import TestApp as WebTestApp 5 | 6 | from tests.acceptance.test_helper import generate_app_main 7 | 8 | 9 | def test_log_new_client_spans(): 10 | # Tests that log lines with 'service_name' keys are logged as 11 | # new client spans. 12 | settings = {'zipkin.tracing_percent': 100} 13 | app_main, transport, _ = generate_app_main(settings) 14 | 15 | WebTestApp(app_main).get('/sample_v2_client', status=200) 16 | 17 | assert len(transport.output) == 1 18 | span_list = json.loads(transport.output[0]) 19 | assert len(span_list) == 3 20 | foo_span = span_list[0] 21 | bar_span = span_list[1] 22 | server_span = span_list[2] 23 | 24 | # Some sanity checks on the new client spans 25 | for client_span in (foo_span, bar_span): 26 | assert client_span['parentId'] == server_span['id'] 27 | assert foo_span['id'] != bar_span['id'] 28 | assert foo_span['annotations'] == [ 29 | {'timestamp': 1664500002000000, 'value': 'foo_client'}, 30 | ] 31 | assert bar_span['annotations'] == [ 32 | {'timestamp': 1664500001000000, 'value': 'bar_client'}, 33 | ] 34 | 35 | 36 | def test_headers_created_for_sampled_child_span( 37 | mock_generate_random_128bit_string, 38 | mock_generate_random_64bit_string, 39 | ): 40 | # Simple smoke test for create_headers_for_new_span 41 | settings = {'zipkin.tracing_percent': 100} 42 | 43 | _assert_headers_present(settings, is_sampled='1') 44 | 45 | 46 | def test_headers_created_for_unsampled_child_span( 47 | mock_generate_random_128bit_string, 48 | mock_generate_random_64bit_string, 49 | ): 50 | # Headers are still created if the span is unsampled 51 | settings = {'zipkin.tracing_percent': 0} 52 | _assert_headers_present(settings, is_sampled='0') 53 | 54 | 55 | def _assert_headers_present(settings, is_sampled): 56 | # Helper method for smoke testing proper setting of headers. 57 | # TraceId and ParentSpanId are set by 128bit and 64bit random string 58 | # generators from py_zipkin. They are mocked in upstream test methods. 59 | expected = { 60 | 'X-B3-Flags': '0', 61 | 'X-B3-ParentSpanId': '17133d482ba4f605', 62 | 'X-B3-Sampled': is_sampled, 63 | 'X-B3-TraceId': '66ec982fcfba8bf3b32d71d76e4a16a3', 64 | } 65 | 66 | app_main, _, _ = generate_app_main(settings) 67 | headers = WebTestApp(app_main).get('/sample_child_span', 68 | status=200) 69 | headers_json = headers.json 70 | headers_json.pop('X-B3-SpanId') # Randomly generated - Ignore. 71 | 72 | assert expected == headers_json 73 | 74 | 75 | def test_span_context(): 76 | # Tests that log lines with 'service_name' keys are logged as 77 | # new client spans. 78 | settings = {'zipkin.tracing_percent': 100} 79 | app_main, transport, _ = generate_app_main(settings) 80 | 81 | WebTestApp(app_main).get('/span_context', status=200) 82 | 83 | # Spans are batched 84 | # The order of span logging goes from innermost (grandchild) up. 85 | assert len(transport.output) == 1 86 | span_list = json.loads(transport.output[0]) 87 | assert len(span_list) == 3 88 | grandchild_span = span_list[0] 89 | child_span = span_list[1] 90 | server_span = span_list[2] 91 | 92 | # Assert proper hierarchy 93 | assert child_span['parentId'] == server_span['id'] 94 | assert grandchild_span['parentId'] == child_span['id'] 95 | # Assert annotations are properly assigned 96 | assert child_span['annotations'] == [ 97 | {'timestamp': 1664500000000000, 'value': 'child_annotation'}, 98 | ] 99 | assert child_span['tags'] == {'foo': 'bar', 'child': 'true'} 100 | assert grandchild_span['annotations'] == [ 101 | {'timestamp': 1664500000000000, 'value': 'grandchild_annotation'}, 102 | ] 103 | assert grandchild_span['tags'] == {'grandchild': 'true'} 104 | 105 | 106 | def test_decorator(): 107 | # Tests that log lines with 'service_name' keys are logged as 108 | # new client spans. 109 | settings = {'zipkin.tracing_percent': 100} 110 | app_main, transport, _ = generate_app_main(settings) 111 | 112 | WebTestApp(app_main).get('/decorator_context', status=200) 113 | 114 | # Two spans are logged - child span, then server span 115 | assert len(transport.output) == 1 116 | span_list = json.loads(transport.output[0]) 117 | assert len(span_list) == 2 118 | child_span = span_list[0] 119 | server_span = span_list[1] 120 | 121 | # Assert proper hierarchy and annotations 122 | assert child_span['parentId'] == server_span['id'] 123 | assert child_span['tags'] == {'a': '1'} 124 | assert child_span['name'] == 'my_span' 125 | 126 | 127 | def test_add_logging_annotation(): 128 | settings = { 129 | 'zipkin.tracing_percent': 100, 130 | 'zipkin.add_logging_annotation': True, 131 | } 132 | app_main, transport, _ = generate_app_main(settings) 133 | 134 | WebTestApp(app_main).get('/sample', status=200) 135 | 136 | assert len(transport.output) == 1 137 | span_list = json.loads(transport.output[0]) 138 | assert len(span_list) == 1 139 | server_span = span_list[0] 140 | 141 | # Just make sure py-zipkin added an annotation for when logging started 142 | assert server_span['annotations'] == [ 143 | {'timestamp': mock.ANY, 'value': 'py_zipkin.logging_end'}, 144 | ] 145 | -------------------------------------------------------------------------------- /tests/acceptance/test_helper.py: -------------------------------------------------------------------------------- 1 | from py_zipkin.transport import BaseTransportHandler 2 | 3 | from .app import main 4 | 5 | 6 | class MockTransport(BaseTransportHandler): 7 | def __init__(self, *argv, **kwargs): 8 | super(BaseTransportHandler, self).__init__(*argv, **kwargs) 9 | self.output = [] 10 | 11 | def get_max_payload_bytes(self): 12 | return None 13 | 14 | def send(self, payload): 15 | self.output.append(payload) 16 | 17 | def get_payloads(self): 18 | """Returns the encoded spans that were sent. 19 | 20 | Spans are batched before being sent, so most of the time the returned 21 | list will contain only one element. Each element is going to be an encoded 22 | list of spans. 23 | """ 24 | return self.output 25 | 26 | 27 | def generate_app_main(settings, firehose=False): 28 | normal_transport = MockTransport() 29 | firehose_transport = MockTransport() 30 | app_main = main({}, **settings) 31 | app_main.registry.settings['zipkin.transport_handler'] = normal_transport 32 | if firehose: 33 | app_main.registry.settings['zipkin.firehose_handler'] = firehose_transport 34 | return app_main, normal_transport, firehose_transport 35 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import pytest 4 | from pyramid.request import Request 5 | 6 | from pyramid_zipkin import request_helper 7 | 8 | 9 | @pytest.fixture 10 | def dummy_request(): 11 | request = mock.Mock() 12 | request.registry.settings = {} 13 | request.headers = {} 14 | request.unique_request_id = '17133d482ba4f605' 15 | request.path = 'bla' 16 | return request 17 | 18 | 19 | @pytest.fixture 20 | def get_request(): 21 | request = Request.blank('GET /sample') 22 | request.registry = mock.Mock() 23 | request.registry.settings = {} 24 | request.headers = {} 25 | request.unique_request_id = '17133d482ba4f605' 26 | return request 27 | 28 | 29 | @pytest.fixture 30 | def dummy_response(): 31 | response = mock.Mock() 32 | response.status_code = 200 33 | return response 34 | 35 | 36 | @pytest.fixture 37 | def zipkin_attributes(): 38 | return {'trace_id': '17133d482ba4f6057289accf83b0adef', 39 | 'span_id': '27133d482ba4f605', 40 | 'parent_span_id': '37133d482ba4f605', 41 | 'flags': '45', 42 | } 43 | 44 | 45 | @pytest.fixture 46 | def sampled_zipkin_attr(zipkin_attributes): 47 | return request_helper.ZipkinAttrs(is_sampled=True, **zipkin_attributes) 48 | 49 | 50 | @pytest.fixture 51 | def unsampled_zipkin_attr(zipkin_attributes): 52 | return request_helper.ZipkinAttrs(is_sampled=False, **zipkin_attributes) 53 | -------------------------------------------------------------------------------- /tests/request_helper_test.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from pyramid_zipkin import request_helper 4 | 5 | 6 | def test_should_not_sample_path_returns_true_if_path_is_blacklisted( 7 | dummy_request 8 | ): 9 | dummy_request.registry.settings = {'zipkin.blacklisted_paths': [r'^/foo/?']} 10 | for path in ['/foo', '/foo/1/3', '/foo/bar', '/foobar']: 11 | dummy_request.path = path 12 | assert request_helper.should_not_sample_path(dummy_request) 13 | 14 | 15 | def test_should_not_sample_path_returns_false_if_path_not_blacklisted( 16 | dummy_request 17 | ): 18 | dummy_request.registry.settings = {'zipkin.blacklisted_paths': [r'^/foo/?']} 19 | for path in ['/bar', '/bar/foo', '/foe']: 20 | dummy_request.path = path 21 | assert not request_helper.should_not_sample_path(dummy_request) 22 | 23 | 24 | def test_should_not_sample_route_returns_true_if_route_is_blacklisted( 25 | dummy_request 26 | ): 27 | dummy_request.registry.settings = {'zipkin.blacklisted_routes': ['foo.bar']} 28 | route = mock.Mock() 29 | route.name = 'foo.bar' 30 | dummy_request.registry.queryUtility = lambda _: lambda req: {'route': route} 31 | assert request_helper.should_not_sample_route(dummy_request) 32 | 33 | 34 | def test_should_not_sample_route_returns_false_if_route_is_not_blacklisted( 35 | dummy_request 36 | ): 37 | dummy_request.registry.settings = {'zipkin.blacklisted_routes': ['bar']} 38 | route = mock.Mock() 39 | route.name = 'foo' 40 | dummy_request.registry.queryUtility = lambda _: lambda req: {'route': route} 41 | assert not request_helper.should_not_sample_route(dummy_request) 42 | 43 | 44 | def test_should_not_sample_route_returns_false_if_blacklisted_list_is_empty( 45 | dummy_request 46 | ): 47 | assert not request_helper.should_not_sample_route(dummy_request) 48 | 49 | 50 | def test_should_be_sampled_as_per_zipkin_tracing_percent_retrns_true_for_100(): 51 | assert request_helper.should_sample_as_per_zipkin_tracing_percent(100.0) 52 | 53 | 54 | def test_should_be_sampled_as_per_zipkin_tracing_percent_returns_false_for_0(): 55 | assert not request_helper.should_sample_as_per_zipkin_tracing_percent(0) 56 | 57 | 58 | @mock.patch('pyramid_zipkin.request_helper.should_not_sample_path', autospec=True) 59 | def test_is_tracing_returns_false_if_path_should_not_be_sampled( 60 | mock, dummy_request 61 | ): 62 | mock.return_value = True 63 | assert not request_helper.is_tracing(dummy_request) 64 | 65 | 66 | @mock.patch( 67 | 'pyramid_zipkin.request_helper.should_not_sample_route', 68 | autospec=True 69 | ) 70 | def test_is_tracing_return_false_if_route_should_not_be_sampled( 71 | mock, dummy_request 72 | ): 73 | mock.return_value = True 74 | assert not request_helper.is_tracing(dummy_request) 75 | 76 | 77 | def test_is_tracing_returns_true_if_sampled_value_in_header_was_true( 78 | dummy_request 79 | ): 80 | dummy_request.headers = {'X-B3-Sampled': '1'} 81 | assert request_helper.is_tracing(dummy_request) 82 | 83 | 84 | def test_is_tracing_returns_false_if_sampled_value_in_header_was_fals( 85 | dummy_request 86 | ): 87 | dummy_request.headers = {'X-B3-Sampled': '0'} 88 | assert not request_helper.is_tracing(dummy_request) 89 | 90 | 91 | @mock.patch( 92 | 'pyramid_zipkin.request_helper.should_sample_as_per_zipkin_tracing_percent', 93 | autospec=True 94 | ) 95 | def test_is_tracing_returns_what_tracing_percent_method_returns_for_rest( 96 | mock, dummy_request 97 | ): 98 | dummy_request.zipkin_trace_id = '42' 99 | assert mock.return_value == request_helper.is_tracing(dummy_request) 100 | mock.assert_called_once_with( 101 | request_helper.DEFAULT_REQUEST_TRACING_PERCENT 102 | ) 103 | 104 | 105 | def test_get_trace_id_returns_header_value_if_present_64bit(dummy_request): 106 | dummy_request.headers = {'X-B3-TraceId': '48485a3953bb6124'} 107 | dummy_request.registry.settings = {} 108 | assert '48485a3953bb6124' == request_helper.get_trace_id(dummy_request) 109 | 110 | 111 | def test_get_trace_id_returns_header_value_if_present_128bit(dummy_request): 112 | # When someone passes a 128-bit trace id, it ends up as 32 hex characters. 113 | dummy_request.headers = {'X-B3-TraceId': '463ac35c9f6413ad48485a3953bb6124'} 114 | dummy_request.registry.settings = {} 115 | assert '463ac35c9f6413ad48485a3953bb6124' == \ 116 | request_helper.get_trace_id(dummy_request) 117 | 118 | 119 | def test_create_zipkin_attr_runs_custom_is_tracing_if_present(dummy_request): 120 | is_tracing = mock.Mock(return_value=True) 121 | dummy_request.registry.settings = { 122 | 'zipkin.is_tracing': is_tracing, 123 | } 124 | request_helper.create_zipkin_attr(dummy_request) 125 | is_tracing.assert_called_once_with(dummy_request) 126 | 127 | 128 | @mock.patch( 129 | 'pyramid_zipkin.request_helper.generate_random_128bit_string', 130 | autospec=True 131 | ) 132 | def test_get_trace_id_returns_some_random_id_by_default( 133 | mock_gen_random, dummy_request 134 | ): 135 | mock_gen_random.return_value = '37133d482ba4f605890bccef7abce8f0' 136 | assert '37133d482ba4f605890bccef7abce8f0' == \ 137 | request_helper.get_trace_id(dummy_request) 138 | 139 | 140 | @mock.patch('pyramid_zipkin.request_helper.is_tracing', autospec=True) 141 | def test_create_sampled_zipkin_attr_creates_ZipkinAttr_object( 142 | mock_is_tracing, dummy_request 143 | ): 144 | mock_is_tracing.return_value = 'bla' 145 | dummy_request.zipkin_trace_id = '12' 146 | dummy_request.headers = { 147 | 'X-B3-TraceId': '12', 148 | 'X-B3-SpanId': '23', 149 | 'X-B3-ParentSpanId': '34', 150 | 'X-B3-Flags': '45', 151 | } 152 | zipkin_attr = request_helper.ZipkinAttrs( 153 | trace_id='12', 154 | span_id='23', 155 | parent_span_id='34', 156 | flags='45', 157 | is_sampled='bla' 158 | ) 159 | assert zipkin_attr == request_helper.create_zipkin_attr(dummy_request) 160 | 161 | 162 | def test_update_annotations_from_request_environ(): 163 | annotation = {} 164 | request_environ = { 165 | 'REQUEST_METHOD': 'GET', 166 | } 167 | request_helper._update_annotations_from_request_environ( 168 | request_environ, annotation 169 | ) 170 | assert annotation == { 171 | 'http.request.method': 'GET', 172 | } 173 | request_environ = { 174 | 'REQUEST_METHOD': 'POST', 175 | 'PATH_INFO': '/foo', 176 | 'QUERY_STRING': 'bar=baz', 177 | 'SERVER_PROTOCOL': 'HTTP/1.1', 178 | 'HTTP_USER_AGENT': 'test-agent', 179 | } 180 | annotation = {} 181 | request_helper._update_annotations_from_request_environ( 182 | request_environ, annotation 183 | ) 184 | assert annotation == { 185 | 'http.request.method': 'POST', 186 | 'url.path': '/foo', 187 | 'network.protocol.version': 'HTTP/1.1', 188 | 'user_agent.original': 'test-agent', 189 | 'url.query': 'bar=baz', 190 | } 191 | request_environ = {} 192 | annotation = {} 193 | request_helper._update_annotations_from_request_environ( 194 | request_environ, annotation 195 | ) 196 | assert annotation == {} 197 | request_environ = { 198 | 'SERVER_NAME': 'localhost', 199 | 'SERVER_PORT': '8080', 200 | 'wsgi.url_scheme': 'http', 201 | } 202 | annotation = {} 203 | request_helper._update_annotations_from_request_environ( 204 | request_environ, annotation 205 | ) 206 | assert annotation == { 207 | 'server.address': 'localhost', 208 | 'server.port': '8080', 209 | 'url.scheme': 'http', 210 | } 211 | -------------------------------------------------------------------------------- /tests/tween_test.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import json 3 | from unittest import mock 4 | 5 | import pytest 6 | from py_zipkin.storage import get_default_tracer 7 | from py_zipkin.storage import Stack 8 | 9 | from pyramid_zipkin import tween 10 | from tests.acceptance.test_helper import MockTransport 11 | 12 | 13 | DummyRequestContext = collections.namedtuple( 14 | 'RequestContext', 15 | ['zipkin_context'], 16 | ) 17 | 18 | 19 | @pytest.mark.parametrize('is_tracing', [True, False]) 20 | @mock.patch.object(get_default_tracer(), 'zipkin_span', autospec=True) 21 | def test_zipkin_tween_sampling( 22 | mock_span, 23 | dummy_request, 24 | dummy_response, 25 | is_tracing, 26 | ): 27 | """ 28 | We should enter py_zipkin context manager and 29 | generate a trace id regardless of whether we are sampling 30 | """ 31 | dummy_request.registry.settings = { 32 | 'zipkin.is_tracing': lambda _: is_tracing, 33 | 'zipkin.transport_handler': MockTransport(), 34 | } 35 | handler = mock.Mock() 36 | handler.return_value = dummy_response 37 | 38 | assert tween.zipkin_tween(handler, None)(dummy_request) == dummy_response 39 | assert handler.call_count == 1 40 | assert mock_span.call_count == 1 41 | 42 | 43 | @pytest.mark.parametrize(['set_callback', 'called'], [(False, 0), (True, 1)]) 44 | @pytest.mark.parametrize('is_tracing', [True, False]) 45 | @mock.patch.object(get_default_tracer(), 'zipkin_span', autospec=True) 46 | def test_zipkin_tween_post_handler_hook( 47 | mock_span, 48 | dummy_request, 49 | dummy_response, 50 | is_tracing, 51 | set_callback, 52 | called, 53 | ): 54 | """ 55 | We should invoke the post processor callback regardless of trace id 56 | or sampling 57 | """ 58 | mock_post_handler_hook = mock.Mock() 59 | 60 | dummy_request.registry.settings = { 61 | 'zipkin.is_tracing': lambda _: is_tracing, 62 | 'zipkin.transport_handler': MockTransport(), 63 | } 64 | if set_callback: 65 | dummy_request.registry.settings['zipkin.post_handler_hook'] = \ 66 | mock_post_handler_hook 67 | 68 | handler = mock.Mock() 69 | handler.return_value = dummy_response 70 | 71 | assert tween.zipkin_tween(handler, None)(dummy_request) == dummy_response 72 | assert handler.call_count == 1 73 | assert mock_span.call_count == 1 74 | assert mock_post_handler_hook.call_count == called 75 | if set_callback: 76 | mock_post_handler_hook.assert_called_once_with( 77 | dummy_request, 78 | dummy_response, 79 | mock_span.return_value.__enter__.return_value, 80 | ) 81 | 82 | 83 | @pytest.mark.parametrize(['set_callback', 'called'], [(False, 0), (True, 1)]) 84 | @pytest.mark.parametrize('is_tracing', [True]) 85 | def test_zipkin_tween_exception( 86 | get_request, 87 | dummy_response, 88 | is_tracing, 89 | set_callback, 90 | called, 91 | ): 92 | """ 93 | If request processing has an exception set 94 | response_status_code and http.response.status_code to 500 95 | """ 96 | 97 | mock_post_handler_hook = mock.Mock() 98 | transport = MockTransport() 99 | 100 | get_request.registry.settings = { 101 | 'zipkin.is_tracing': lambda _: is_tracing, 102 | 'zipkin.transport_handler': transport, 103 | } 104 | if set_callback: 105 | get_request.registry.settings['zipkin.post_handler_hook'] = \ 106 | mock_post_handler_hook 107 | 108 | handler = mock.Mock(side_effect=Exception) 109 | 110 | try: 111 | tween.zipkin_tween(handler, dummy_response)(get_request) 112 | pytest.fail('exception was expected to be thrown!') 113 | except Exception: 114 | pass 115 | 116 | spans = transport.get_payloads() 117 | assert len(spans) == 1 118 | span = json.loads(spans[0])[0] 119 | span['tags']['response_status_code'] = '500' 120 | span['tags']['http.response.status_code'] = '500' 121 | span['tags']['error.type'] = 'Exception' 122 | 123 | assert handler.call_count == 1 124 | assert mock_post_handler_hook.call_count == called 125 | 126 | 127 | @pytest.mark.parametrize(['set_callback', 'called'], [(False, 0), (True, 1)]) 128 | @pytest.mark.parametrize('is_tracing', [True]) 129 | def test_zipkin_tween_no_exception( 130 | get_request, 131 | dummy_response, 132 | is_tracing, 133 | set_callback, 134 | called, 135 | ): 136 | mock_post_handler_hook = mock.Mock() 137 | transport = MockTransport() 138 | 139 | get_request.registry.settings = { 140 | 'zipkin.is_tracing': lambda _: is_tracing, 141 | 'zipkin.transport_handler': transport, 142 | } 143 | if set_callback: 144 | get_request.registry.settings['zipkin.post_handler_hook'] = \ 145 | mock_post_handler_hook 146 | 147 | handler = mock.Mock() 148 | tween.zipkin_tween(handler, dummy_response)(get_request) 149 | 150 | spans = transport.get_payloads() 151 | assert len(spans) == 1 152 | span = json.loads(spans[0])[0] 153 | span['tags']['response_status_code'] = '200' 154 | span['tags']['http.response.status_code'] = '200' 155 | 156 | assert handler.call_count == 1 157 | assert mock_post_handler_hook.call_count == called 158 | 159 | 160 | def test_zipkin_tween_context_stack( 161 | dummy_request, 162 | dummy_response, 163 | ): 164 | old_context_stack = get_default_tracer()._context_stack 165 | dummy_request.registry.settings = { 166 | 'zipkin.is_tracing': lambda _: False, 167 | 'zipkin.transport_handler': MockTransport(), 168 | 'zipkin.request_context': 'rctxstorage.zipkin_context', 169 | } 170 | 171 | context_stack = mock.Mock(spec=Stack) 172 | dummy_request.rctxstorage = DummyRequestContext( 173 | zipkin_context=context_stack, 174 | ) 175 | 176 | handler = mock.Mock(return_value=dummy_response) 177 | response = tween.zipkin_tween(handler, None)(dummy_request) 178 | get_default_tracer()._context_stack = old_context_stack 179 | 180 | assert response == dummy_response 181 | 182 | assert context_stack.push.call_count == 1 183 | assert context_stack.pop.call_count == 1 184 | 185 | 186 | def test_getattr_path(): 187 | request_context = DummyRequestContext(zipkin_context=object()) 188 | assert tween._getattr_path(request_context, 'zipkin_context.__class__') 189 | 190 | # missing attribute 191 | tween._getattr_path(request_context, 'zipkin_context.missing') is None 192 | 193 | # attribute is None 194 | mock_object = mock.Mock(nil=None) 195 | assert tween._getattr_path(mock_object, 'nil') is None 196 | 197 | 198 | def test_logs_warning_if_using_function_as_transport( 199 | dummy_request, 200 | dummy_response, 201 | ): 202 | dummy_request.registry.settings = { 203 | 'zipkin.is_tracing': lambda _: False, 204 | 'zipkin.transport_handler': lambda x: None, 205 | 'request_context': 'rctxstorage', 206 | } 207 | 208 | handler = mock.Mock(return_value=dummy_response) 209 | with pytest.deprecated_call(): 210 | tween.zipkin_tween(handler, None)(dummy_request) 211 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = pre-commit, py38, py310, flake8, pre-commit 3 | 4 | [testenv] 5 | deps = -rrequirements-dev.txt 6 | commands = 7 | coverage erase 8 | coverage run --source=pyramid_zipkin/ -m pytest -vv {posargs:tests} 9 | coverage report -m --show-missing --fail-under 100 10 | mypy pyramid_zipkin/ 11 | 12 | [testenv:pre-commit] 13 | basepython = python3.8 14 | deps = pre-commit 15 | commands = pre-commit {posargs:run --all-files} 16 | 17 | [testenv:flake8] 18 | basepython = python3.8 19 | deps = flake8 20 | commands = 21 | flake8 pyramid_zipkin tests 22 | 23 | [testenv:docs] 24 | basepython = python3.8 25 | deps = {[testenv]deps} 26 | sphinx 27 | changedir = docs 28 | commands = sphinx-build -b html -d build/doctrees source build/html 29 | 30 | [flake8] 31 | exclude = .svn,CVS,.bzr,.hg,.git,__pycache__,.tox,docs 32 | max_line_length = 82 33 | --------------------------------------------------------------------------------