diff --git a/.changelog/1.added b/.changelog/1.added new file mode 100644 index 0000000000..e502890cd0 --- /dev/null +++ b/.changelog/1.added @@ -0,0 +1 @@ +`opentelemetry-instrumentation-faust`: add instrumentation for faust-streaming diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 2a192de85b..00eb8829d6 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -15,6 +15,9 @@ components: instrumentation/opentelemetry-instrumentation-exceptions: - iblancasa + instrumentation/opentelemetry-instrumentation-faust: + - wbarnha + instrumentation/opentelemetry-instrumentation-botocore: - lukeina2z - yiyuan-he diff --git a/.github/workflows/core_contrib_test.yml b/.github/workflows/core_contrib_test.yml index be80dd5e94..b2cf6d8457 100644 --- a/.github/workflows/core_contrib_test.yml +++ b/.github/workflows/core_contrib_test.yml @@ -1467,6 +1467,36 @@ jobs: - name: Run tests run: tox -e py310-test-instrumentation-fastapi-latest -- -ra + py310-test-instrumentation-faust: + name: instrumentation-faust + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout contrib repo @ SHA - ${{ env.CONTRIB_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python-contrib + ref: ${{ env.CONTRIB_REPO_SHA }} + + - name: Checkout core repo @ SHA - ${{ env.CORE_REPO_SHA }} + uses: actions/checkout@v4 + with: + repository: open-telemetry/opentelemetry-python + ref: ${{ env.CORE_REPO_SHA }} + path: opentelemetry-python + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + architecture: "x64" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-faust -- -ra + py310-test-instrumentation-flask-0: name: instrumentation-flask-0 runs-on: ubuntu-latest diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a6437462bc..83fd88420a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -480,6 +480,25 @@ jobs: - name: Run tests run: tox -e lint-instrumentation-fastapi + lint-instrumentation-faust: + name: instrumentation-faust + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e lint-instrumentation-faust + lint-instrumentation-flask: name: instrumentation-flask runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f27399a863..4efdc19422 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4850,6 +4850,63 @@ jobs: - name: Run tests run: tox -e pypy3-test-instrumentation-fastapi-latest -- -ra + py310-test-instrumentation-faust_ubuntu-latest: + name: instrumentation-faust 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-faust -- -ra + + py311-test-instrumentation-faust_ubuntu-latest: + name: instrumentation-faust 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py311-test-instrumentation-faust -- -ra + + py312-test-instrumentation-faust_ubuntu-latest: + name: instrumentation-faust 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py312-test-instrumentation-faust -- -ra + py310-test-instrumentation-flask-0_ubuntu-latest: name: instrumentation-flask-0 3.10 Ubuntu runs-on: ubuntu-latest diff --git a/docs-requirements.txt b/docs-requirements.txt index 435f897131..a8b594a89e 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -29,6 +29,7 @@ celery>=4.0 confluent-kafka>= 1.8.2,< 3.0.0 flask~=2.0 falcon~=2.0 +faust-streaming~=0.11 grpcio~=1.27 httpx>=0.18.0 kafka-python>=2.0,<3.0 diff --git a/docs/instrumentation/faust/faust.rst b/docs/instrumentation/faust/faust.rst new file mode 100644 index 0000000000..8a00e86ff1 --- /dev/null +++ b/docs/instrumentation/faust/faust.rst @@ -0,0 +1,10 @@ +.. include:: ../../../instrumentation/opentelemetry-instrumentation-faust/README.rst + :end-before: References + +API +--- + +.. automodule:: opentelemetry.instrumentation.faust + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/nitpick-exceptions.ini b/docs/nitpick-exceptions.ini index 2c069ab847..57b9fe82a4 100644 --- a/docs/nitpick-exceptions.ini +++ b/docs/nitpick-exceptions.ini @@ -49,6 +49,26 @@ py-class= _wrappers.ObjectProxy fastapi.applications.FastAPI starlette.applications.Starlette + ; faust-streaming + faust.App + faust.app.base.App + faust.sensors.base.Sensor + faust.types.tuples.TP + faust.types.tuples.PendingMessage + faust.types.tuples.RecordMetadata + faust.types.events.EventT + faust.types.streams.StreamT + faust.types.transports.ProducerT + TP + PendingMessage + RecordMetadata + EventT + StreamT + ProducerT + AppT + ProduceHookT + ProcessHookT + trace.TracerProvider _contextvars.Token opentelemetry.instrumentation.httpx._SyncOpenTelemetryTransportBase opentelemetry.instrumentation.httpx._AsyncOpenTelemetryTransportBase diff --git a/instrumentation/README.md b/instrumentation/README.md index 4c682e78ef..1563196179 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -22,6 +22,7 @@ | [opentelemetry-instrumentation-exceptions](./opentelemetry-instrumentation-exceptions) | exceptions | No | development | [opentelemetry-instrumentation-falcon](./opentelemetry-instrumentation-falcon) | falcon >= 1.4.1, < 5.0.0 | Yes | migration | [opentelemetry-instrumentation-fastapi](./opentelemetry-instrumentation-fastapi) | fastapi ~= 0.92 | Yes | migration +| [opentelemetry-instrumentation-faust](./opentelemetry-instrumentation-faust) | faust-streaming >= 0.10, < 1.0 | No | development | [opentelemetry-instrumentation-flask](./opentelemetry-instrumentation-flask) | flask >= 1.0 | Yes | migration | [opentelemetry-instrumentation-grpc](./opentelemetry-instrumentation-grpc) | grpcio >= 1.42.0 | No | development | [opentelemetry-instrumentation-httpx](./opentelemetry-instrumentation-httpx) | httpx >= 0.18.0,httpx2 >= 2.0.0 | Yes | migration diff --git a/instrumentation/opentelemetry-instrumentation-faust/LICENSE b/instrumentation/opentelemetry-instrumentation-faust/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-faust/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/instrumentation/opentelemetry-instrumentation-faust/README.rst b/instrumentation/opentelemetry-instrumentation-faust/README.rst new file mode 100644 index 0000000000..e62714c924 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-faust/README.rst @@ -0,0 +1,22 @@ +OpenTelemetry faust-streaming instrumentation +============================================= + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-faust.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-faust/ + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-faust + + +References +---------- + +* `OpenTelemetry faust-streaming Instrumentation `_ +* `OpenTelemetry Project `_ +* `OpenTelemetry Python Examples `_ diff --git a/instrumentation/opentelemetry-instrumentation-faust/pyproject.toml b/instrumentation/opentelemetry-instrumentation-faust/pyproject.toml new file mode 100644 index 0000000000..9e59af61ad --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-faust/pyproject.toml @@ -0,0 +1,49 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-faust" +dynamic = ["version"] +description = "OpenTelemetry faust-streaming instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "opentelemetry-api ~= 1.27", + "opentelemetry-instrumentation == 0.66b0.dev", + "opentelemetry-semantic-conventions == 0.66b0.dev", + "typing_extensions ~= 4.1", +] + +[project.optional-dependencies] +instruments = ["faust-streaming >= 0.10, < 1.0"] + +[project.entry-points.opentelemetry_instrumentor] +faust = "opentelemetry.instrumentation.faust:FaustInstrumentor" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation/opentelemetry-instrumentation-faust" +Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/faust/version.py" + +[tool.hatch.build.targets.sdist] +include = ["/src", "/tests"] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/__init__.py b/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/__init__.py new file mode 100644 index 0000000000..9730df1e67 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/__init__.py @@ -0,0 +1,238 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +Instrument `faust-streaming`_ to trace messages sent to Kafka and events +processed by faust agents. + +.. _faust-streaming: https://pypi.org/project/faust-streaming/ + +The instrumentation relies on faust's built-in sensor API: every +``faust.App`` created after calling ``instrument()`` gets an +:class:`~opentelemetry.instrumentation.faust.OpenTelemetrySensor` +added to it. The sensor: + +* starts a ``PRODUCER`` span for every message sent to a Kafka topic + (``Topic.send()``, ``App.send()``, agent replies, and so on) and injects + the span context into the Kafka message headers, +* starts a ``CONSUMER`` span around every event processed by a stream + (for example the body of a faust agent), using the context extracted + from the incoming Kafka message headers as parent. + +Applications created *before* calling ``instrument()`` are not +instrumented automatically; use ``FaustInstrumentor().instrument_app(app)`` +to instrument them explicitly. + +Usage +----- + +.. code:: python + + import faust + from opentelemetry.instrumentation.faust import FaustInstrumentor + + # Instrument faust before creating the app + FaustInstrumentor().instrument() + + app = faust.App("example", broker="kafka://localhost:9092") + orders_topic = app.topic("orders") + + @app.agent(orders_topic) + async def process_orders(orders): + async for order in orders: + ... # each event is processed inside a consumer span + + async def send_order(): + # produces a producer span and propagates its context via headers + await orders_topic.send(value=b"order payload") + +The ``instrument()`` method accepts the following keyword args: + +- **tracer_provider** (TracerProvider) - an optional tracer provider + +- **produce_hook** (Callable) - a function with extra user-defined logic + to be performed before sending a message + + Function signature: + + .. code:: python + + def produce_hook(span: Span, message: faust.types.tuples.PendingMessage): ... + +- **process_hook** (Callable) - a function with extra user-defined logic + to be performed when a stream starts processing an event + + Function signature: + + .. code:: python + + def process_hook(span: Span, event: faust.types.EventT): ... + +For example: + +.. code:: python + + import faust + from opentelemetry.instrumentation.faust import FaustInstrumentor + + def produce_hook(span, message): + if span and span.is_recording(): + span.set_attribute("custom_user_attribute_from_produce_hook", "some-value") + + def process_hook(span, event): + if span and span.is_recording(): + span.set_attribute("custom_user_attribute_from_process_hook", "some-value") + + # instrument faust with produce and process hooks + FaustInstrumentor().instrument(produce_hook=produce_hook, process_hook=process_hook) + +API +--- +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Callable, Collection +from weakref import WeakKeyDictionary + +import faust +from wrapt import wrap_function_wrapper + +from opentelemetry import trace +from opentelemetry.instrumentation.faust.package import _instruments +from opentelemetry.instrumentation.faust.sensor import OpenTelemetrySensor +from opentelemetry.instrumentation.faust.version import __version__ +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.semconv.schemas import Schemas + +if TYPE_CHECKING: + from typing import TypedDict + + from typing_extensions import Unpack + + from .sensor import ProcessHookT, ProduceHookT + + class InstrumentKwargs(TypedDict, total=False): + tracer_provider: trace.TracerProvider + produce_hook: ProduceHookT + process_hook: ProcessHookT + + class UninstrumentKwargs(TypedDict, total=False): + pass + + +__all__ = ["FaustInstrumentor", "OpenTelemetrySensor"] + +_instrumented_apps: WeakKeyDictionary[faust.App, OpenTelemetrySensor] = ( + WeakKeyDictionary() +) + + +def _add_sensor( + app: faust.App, + tracer: trace.Tracer, + produce_hook: ProduceHookT = None, + process_hook: ProcessHookT = None, +) -> None: + if app in _instrumented_apps: + return + sensor = OpenTelemetrySensor(tracer, produce_hook, process_hook) + app.sensors.add(sensor) + _instrumented_apps[app] = sensor + + +def _remove_sensor(app: faust.App) -> None: + sensor = _instrumented_apps.pop(app, None) + if sensor is None: + return + try: + app.sensors.remove(sensor) + except KeyError: + pass + + +def _wrap_app_init( + tracer: trace.Tracer, + produce_hook: ProduceHookT, + process_hook: ProcessHookT, +) -> Callable[..., None]: + def _traced_init( + func: Callable[..., None], + instance: faust.App, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> None: + func(*args, **kwargs) + _add_sensor(instance, tracer, produce_hook, process_hook) + + return _traced_init + + +class FaustInstrumentor(BaseInstrumentor): + """An instrumentor for faust-streaming + See `BaseInstrumentor` + """ + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs: Unpack[InstrumentKwargs]) -> None: + """Instruments the faust module + + Args: + **kwargs: Optional arguments + ``tracer_provider``: a TracerProvider, defaults to global. + ``produce_hook``: a callable to be executed just before + a message is sent to Kafka. + ``process_hook``: a callable to be executed when a stream + starts processing an event. + """ + tracer_provider = kwargs.get("tracer_provider") + + produce_hook = kwargs.get("produce_hook") + if not callable(produce_hook): + produce_hook = None + + process_hook = kwargs.get("process_hook") + if not callable(process_hook): + process_hook = None + + tracer = trace.get_tracer( + __name__, + __version__, + tracer_provider=tracer_provider, + schema_url=Schemas.V1_27_0.value, + ) + + wrap_function_wrapper( + faust.App, + "__init__", + _wrap_app_init(tracer, produce_hook, process_hook), + ) + + def _uninstrument(self, **kwargs: Unpack[UninstrumentKwargs]) -> None: + unwrap(faust.App, "__init__") + for app in list(_instrumented_apps): + _remove_sensor(app) + + @staticmethod + def instrument_app( + app: faust.App, + tracer_provider: trace.TracerProvider | None = None, + produce_hook: ProduceHookT = None, + process_hook: ProcessHookT = None, + ) -> None: + """Instrument an existing ``faust.App`` instance.""" + tracer = trace.get_tracer( + __name__, + __version__, + tracer_provider=tracer_provider, + schema_url=Schemas.V1_27_0.value, + ) + _add_sensor(app, tracer, produce_hook, process_hook) + + @staticmethod + def uninstrument_app(app: faust.App) -> None: + """Remove instrumentation from a ``faust.App`` instance.""" + _remove_sensor(app) diff --git a/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/package.py b/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/package.py new file mode 100644 index 0000000000..adc9386c42 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/package.py @@ -0,0 +1,5 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + + +_instruments = ("faust-streaming >= 0.10, < 1.0",) diff --git a/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/py.typed b/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/sensor.py b/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/sensor.py new file mode 100644 index 0000000000..627adc0c46 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/sensor.py @@ -0,0 +1,186 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from logging import getLogger +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional +from weakref import WeakKeyDictionary + +from faust.sensors.base import Sensor + +from opentelemetry import context, propagate, trace +from opentelemetry.instrumentation.faust.utils import ( + _build_process_attributes, + _build_send_attributes, + _deserialize_key, + _faust_getter, + _faust_setter, + _get_span_name, +) +from opentelemetry.trace import SpanKind +from opentelemetry.trace.status import Status, StatusCode + +if TYPE_CHECKING: + from faust.types import AppT, EventT, StreamT + from faust.types.transports import ProducerT + from faust.types.tuples import TP, PendingMessage, RecordMetadata + + from opentelemetry.trace import Span, Tracer + + ProduceHookT = Optional[Callable[[Span, PendingMessage], None]] + ProcessHookT = Optional[Callable[[Span, EventT], None]] + +_LOG = getLogger(__name__) + + +class OpenTelemetrySensor(Sensor): # pylint: disable=too-many-ancestors + """A faust sensor that records spans for messages sent to Kafka and + for events processed by faust streams. + + Instances of this sensor are added automatically to every + ``faust.App`` created after calling + ``FaustInstrumentor().instrument()``. + """ + + def __init__( + self, + tracer: Tracer, + produce_hook: ProduceHookT = None, + process_hook: ProcessHookT = None, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self._tracer = tracer + self._produce_hook = produce_hook + self._process_hook = process_hook + # Per-stream state for the event currently being processed. Kept + # so that spans are finished even when faust does not call + # ``on_stream_event_out`` (e.g. for streams with acks disabled). + self._stream_states: WeakKeyDictionary[StreamT, Dict] = ( + WeakKeyDictionary() + ) + + def on_send_initiated( + self, + producer: ProducerT, + topic: str, + message: PendingMessage, + keysize: int, + valsize: int, + ) -> Any: + """Start a producer span and inject its context into the message + headers, just before a message is sent to Kafka.""" + app = producer.app + span = self._tracer.start_span( + _get_span_name("send", topic), + kind=SpanKind.PRODUCER, + attributes=_build_send_attributes( + broker_urls=app.conf.broker, + client_id=app.conf.broker_client_id, + topic=topic, + partition=message.partition, + key=_deserialize_key(message.key), + ), + ) + propagate.inject( + message.headers, + context=trace.set_span_in_context(span), + setter=_faust_setter, + ) + try: + if self._produce_hook is not None: + self._produce_hook(span, message) + except Exception as hook_exception: # pylint: disable=W0703 + _LOG.exception(hook_exception) + return span + + def on_send_completed( + self, producer: ProducerT, state: Any, metadata: RecordMetadata + ) -> None: + """End the producer span once the broker acknowledged the message.""" + span = state + if isinstance(span, trace.Span): + span.end() + + def on_send_error( + self, producer: ProducerT, exc: BaseException, state: Any + ) -> None: + """End the producer span with an error status.""" + span = state + if isinstance(span, trace.Span): + if span.is_recording(): + span.set_status(Status(StatusCode.ERROR, str(exc))) + if isinstance(exc, Exception): + span.record_exception(exc) + span.end() + + def on_stream_event_in( + self, tp: TP, offset: int, stream: StreamT, event: EventT + ) -> Optional[Dict]: + """Start a consumer span when a stream starts processing an event. + + The span context is attached so that any span started while the + event is being processed (including producer spans for messages + sent from the processing agent) becomes a child of it. + """ + # finish any span left dangling because faust never called + # on_stream_event_out for the previous event of this stream + self._finalize_state(self._stream_states.pop(stream, None)) + + app: AppT = stream.app + extracted_context = propagate.extract( + event.headers, getter=_faust_getter + ) + span = self._tracer.start_span( + _get_span_name("process", tp.topic), + context=extracted_context, + kind=SpanKind.CONSUMER, + attributes=_build_process_attributes( + broker_urls=app.conf.broker, + client_id=app.conf.broker_client_id, + consumer_group=app.conf.id, + topic=tp.topic, + partition=tp.partition, + offset=offset, + key=_deserialize_key(event.message.key), + ), + ) + new_context = trace.set_span_in_context(span, extracted_context) + token = context.attach(new_context) + try: + if self._process_hook is not None: + self._process_hook(span, event) + except Exception as hook_exception: # pylint: disable=W0703 + _LOG.exception(hook_exception) + state = {"span": span, "token": token, "context": new_context} + self._stream_states[stream] = state + return state + + def on_stream_event_out( + self, + tp: TP, + offset: int, + stream: StreamT, + event: EventT, + state: Dict = None, + ) -> None: + """End the consumer span when the stream is done processing.""" + stored_state = self._stream_states.pop(stream, None) + self._finalize_state(state if state else stored_state) + + @staticmethod + def _finalize_state(state: Optional[Dict]) -> None: + if not state: + return + token = state.pop("token", None) + attached_context = state.pop("context", None) + # Only detach when the context attached in on_stream_event_in is + # still the active one. When a stream is closed mid-processing + # (worker shutdown, agent crash) this runs in a different context + # where the token is not valid. + if token is not None and attached_context is context.get_current(): + context.detach(token) + span = state.pop("span", None) + if span is not None: + span.end() diff --git a/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/utils.py b/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/utils.py new file mode 100644 index 0000000000..9c2860d548 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/utils.py @@ -0,0 +1,175 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import contextlib +import json +from collections.abc import Mapping, MutableMapping, MutableSequence +from logging import getLogger +from typing import TYPE_CHECKING + +from opentelemetry.propagators import textmap +from opentelemetry.semconv._incubating.attributes import messaging_attributes +from opentelemetry.semconv.attributes import server_attributes +from opentelemetry.util.types import AttributeValue + +if TYPE_CHECKING: + from typing import Iterable, List, Tuple, Union + + from yarl import URL + + # Kafka message headers in the shapes faust passes them around: + # either a (mutable) mapping of name to value or a list of tuples. + HeadersArg = Union[ + MutableMapping[str, bytes], + Mapping[str, bytes], + MutableSequence[Tuple[str, bytes]], + List[Tuple[str, bytes]], + ] + +_LOG = getLogger(__name__) + + +def _header_items(carrier: HeadersArg) -> Iterable[tuple[str, object]]: + if isinstance(carrier, Mapping): + return carrier.items() + return carrier + + +class FaustContextGetter(textmap.Getter["HeadersArg"]): + def get(self, carrier: HeadersArg | None, key: str) -> list[str] | None: + if carrier is None: + return None + for item_key, value in _header_items(carrier): + if item_key == key and value is not None: + if isinstance(value, bytes): + with contextlib.suppress(UnicodeDecodeError): + return [value.decode()] + return None + return [str(value)] + return None + + def keys(self, carrier: HeadersArg | None) -> list[str]: + if carrier is None: + return [] + return [key for (key, _) in _header_items(carrier)] + + +class FaustContextSetter(textmap.Setter["HeadersArg"]): + def set( + self, carrier: HeadersArg | None, key: str | None, value: str | None + ) -> None: + if carrier is None or key is None or value is None: + return + if isinstance(carrier, MutableMapping): + carrier[key] = value.encode() + elif isinstance(carrier, MutableSequence): + carrier.append((key, value.encode())) + else: + _LOG.warning( + "Unable to set context in headers. Headers is immutable" + ) + + +_faust_getter = FaustContextGetter() +_faust_setter = FaustContextSetter() + + +def _deserialize_key(key: object | None) -> str | None: + if key is None: + return None + + if isinstance(key, bytes): + with contextlib.suppress(UnicodeDecodeError): + return key.decode() + return None + + return str(key) + + +def _get_span_name(operation: str, topic: str) -> str: + return f"{topic} {operation}" + + +def _serialize_broker_urls(broker_urls: list[URL]) -> str: + return json.dumps([str(broker_url) for broker_url in broker_urls]) + + +def _build_base_attributes( + *, + broker_urls: list[URL], + client_id: str, + topic: str, + partition: int | None, + key: str | None, +) -> dict[str, AttributeValue]: + attributes: dict[str, AttributeValue] = { + messaging_attributes.MESSAGING_SYSTEM: messaging_attributes.MessagingSystemValues.KAFKA.value, + server_attributes.SERVER_ADDRESS: _serialize_broker_urls(broker_urls), + messaging_attributes.MESSAGING_CLIENT_ID: client_id, + messaging_attributes.MESSAGING_DESTINATION_NAME: topic, + } + if partition is not None: + attributes[messaging_attributes.MESSAGING_DESTINATION_PARTITION_ID] = ( + str(partition) + ) + if key is not None: + attributes[messaging_attributes.MESSAGING_KAFKA_MESSAGE_KEY] = key + return attributes + + +def _build_send_attributes( + *, + broker_urls: list[URL], + client_id: str, + topic: str, + partition: int | None, + key: str | None, +) -> dict[str, AttributeValue]: + attributes = _build_base_attributes( + broker_urls=broker_urls, + client_id=client_id, + topic=topic, + partition=partition, + key=key, + ) + attributes[messaging_attributes.MESSAGING_OPERATION_NAME] = "send" + attributes[messaging_attributes.MESSAGING_OPERATION_TYPE] = ( + messaging_attributes.MessagingOperationTypeValues.PUBLISH.value + ) + return attributes + + +def _build_process_attributes( + *, + broker_urls: list[URL], + client_id: str, + consumer_group: str | None, + topic: str, + partition: int, + offset: int, + key: str | None, +) -> dict[str, AttributeValue]: + attributes = _build_base_attributes( + broker_urls=broker_urls, + client_id=client_id, + topic=topic, + partition=partition, + key=key, + ) + attributes[messaging_attributes.MESSAGING_OPERATION_NAME] = "process" + attributes[messaging_attributes.MESSAGING_OPERATION_TYPE] = ( + messaging_attributes.MessagingOperationTypeValues.PROCESS.value + ) + if consumer_group is not None: + attributes[messaging_attributes.MESSAGING_CONSUMER_GROUP_NAME] = ( + consumer_group + ) + attributes[messaging_attributes.MESSAGING_KAFKA_MESSAGE_OFFSET] = offset + # A message within Kafka is uniquely defined by its topic name, + # topic partition and offset. + attributes[messaging_attributes.MESSAGING_MESSAGE_ID] = ( + f"{topic}.{partition}.{offset}" + ) + return attributes diff --git a/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/version.py b/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/version.py new file mode 100644 index 0000000000..e49b1ceb43 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-faust/src/opentelemetry/instrumentation/faust/version.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +__version__ = "0.66b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-faust/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-faust/test-requirements.txt new file mode 100644 index 0000000000..ae6462a270 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-faust/test-requirements.txt @@ -0,0 +1,4 @@ +faust-streaming==0.11.3 +pytest==7.4.4 +-e opentelemetry-instrumentation +-e instrumentation/opentelemetry-instrumentation-faust diff --git a/instrumentation/opentelemetry-instrumentation-faust/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-faust/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-faust/tests/test_instrumentation.py b/instrumentation/opentelemetry-instrumentation-faust/tests/test_instrumentation.py new file mode 100644 index 0000000000..8d6b9a6038 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-faust/tests/test_instrumentation.py @@ -0,0 +1,457 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +import json +import time +import uuid +from typing import Any +from unittest import IsolatedAsyncioTestCase, TestCase, mock + +import faust +from faust.types.tuples import TP, Message, RecordMetadata +from wrapt import BoundFunctionWrapper + +from opentelemetry import trace +from opentelemetry.instrumentation.faust import ( + FaustInstrumentor, + OpenTelemetrySensor, +) +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.semconv._incubating.attributes import messaging_attributes +from opentelemetry.semconv.attributes import server_attributes +from opentelemetry.test.test_base import TestBase +from opentelemetry.trace import ( + SpanKind, + StatusCode, + format_span_id, + format_trace_id, +) + + +def _make_app(name: str | None = None) -> faust.App: + app = faust.App( + name or f"test-app-{uuid.uuid4()}", + broker="kafka://localhost:9092", + store="memory://", + ) + app.finalize() + return app + + +def _get_sensor(app: faust.App) -> OpenTelemetrySensor | None: + for sensor in app.sensors: + if isinstance(sensor, OpenTelemetrySensor): + return sensor + return None + + +class TestFaustInstrumentor(TestCase): + def tearDown(self) -> None: + instrumentor = FaustInstrumentor() + if instrumentor.is_instrumented_by_opentelemetry: + instrumentor.uninstrument() + + def test_instrument_api(self) -> None: + instrumentation = FaustInstrumentor() + + instrumentation.instrument() + self.assertTrue(isinstance(faust.App.__init__, BoundFunctionWrapper)) + + instrumentation.uninstrument() + self.assertFalse(isinstance(faust.App.__init__, BoundFunctionWrapper)) + + def test_instrument_adds_sensor_to_new_apps(self) -> None: + instrumentation = FaustInstrumentor() + + instrumentation.instrument() + app = _make_app() + self.assertIsNotNone(_get_sensor(app)) + + instrumentation.uninstrument() + self.assertIsNone(_get_sensor(app)) + + def test_apps_created_before_instrument_are_not_instrumented( + self, + ) -> None: + app = _make_app() + instrumentation = FaustInstrumentor() + + instrumentation.instrument() + self.assertIsNone(_get_sensor(app)) + instrumentation.uninstrument() + + def test_instrument_app(self) -> None: + app = _make_app() + instrumentation = FaustInstrumentor() + + instrumentation.instrument_app(app) + self.assertIsNotNone(_get_sensor(app)) + + # instrumenting twice must not add a second sensor + instrumentation.instrument_app(app) + self.assertEqual( + len( + [ + sensor + for sensor in app.sensors + if isinstance(sensor, OpenTelemetrySensor) + ] + ), + 1, + ) + + instrumentation.uninstrument_app(app) + self.assertIsNone(_get_sensor(app)) + + +class TestFaustInstrumentation(TestBase, IsolatedAsyncioTestCase): + def setUp(self) -> None: + super().setUp() + FaustInstrumentor().instrument(tracer_provider=self.tracer_provider) + + def tearDown(self) -> None: + FaustInstrumentor().uninstrument() + super().tearDown() + + @staticmethod + def _mock_producer( + app: faust.App, exception: Exception | None = None + ) -> mock.Mock: + producer = mock.Mock() + producer.app = app + + async def _send( + topic: str, *args: Any, **kwargs: Any + ) -> asyncio.Future: + fut: asyncio.Future = asyncio.get_running_loop().create_future() + if exception is not None: + fut.set_exception(exception) + else: + fut.set_result( + RecordMetadata( + topic=topic, + partition=0, + topic_partition=TP(topic, 0), + offset=1, + timestamp=None, + timestamp_type=None, + ) + ) + return fut + + producer.send = mock.Mock(side_effect=_send) + return producer + + @staticmethod + def _message_factory( + offset: int, headers: list[tuple[str, bytes]] | None = None + ) -> Message: + return Message( + topic="test-topic", + partition=1, + offset=offset, + timestamp=time.time(), + timestamp_type=0, + headers=headers, + key=b"test-key", + value=b"test-value", + checksum=None, + generation_id=0, + ) + + def _assert_common_attributes( + self, span: ReadableSpan, app: faust.App + ) -> None: + self.assertEqual( + span.attributes[messaging_attributes.MESSAGING_SYSTEM], + messaging_attributes.MessagingSystemValues.KAFKA.value, + ) + self.assertEqual( + span.attributes[server_attributes.SERVER_ADDRESS], + json.dumps([str(url) for url in app.conf.broker]), + ) + self.assertEqual( + span.attributes[messaging_attributes.MESSAGING_CLIENT_ID], + app.conf.broker_client_id, + ) + self.assertEqual( + span.attributes[messaging_attributes.MESSAGING_DESTINATION_NAME], + "test-topic", + ) + + async def test_send(self) -> None: + app = _make_app() + producer = self._mock_producer(app) + topic = app.topic("test-topic") + + with mock.patch.object( + app, "maybe_start_producer", mock.AsyncMock(return_value=producer) + ): + await topic.send(key=b"test-key", value=b"test-value") + # let the message-published callback run + await asyncio.sleep(0) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.name, "test-topic send") + self.assertEqual(span.kind, SpanKind.PRODUCER) + self.assertIs(span.status.status_code, StatusCode.UNSET) + self._assert_common_attributes(span, app) + self.assertEqual( + span.attributes[messaging_attributes.MESSAGING_OPERATION_NAME], + "send", + ) + self.assertEqual( + span.attributes[messaging_attributes.MESSAGING_OPERATION_TYPE], + messaging_attributes.MessagingOperationTypeValues.PUBLISH.value, + ) + self.assertEqual( + span.attributes[messaging_attributes.MESSAGING_KAFKA_MESSAGE_KEY], + "test-key", + ) + self.assertNotIn( + messaging_attributes.MESSAGING_DESTINATION_PARTITION_ID, + span.attributes, + ) + + # span context must have been injected into the message headers + producer.send.assert_called_once() + headers = producer.send.call_args.kwargs["headers"] + version, trace_id, span_id, flags = ( + headers["traceparent"].decode().split("-") + ) + self.assertEqual(version, "00") + self.assertEqual(trace_id, format_trace_id(span.context.trace_id)) + self.assertEqual(span_id, format_span_id(span.context.span_id)) + self.assertTrue(int(flags, 16) & 0x01) + + async def test_send_partition_attribute(self) -> None: + app = _make_app() + producer = self._mock_producer(app) + topic = app.topic("test-topic") + + with mock.patch.object( + app, "maybe_start_producer", mock.AsyncMock(return_value=producer) + ): + await topic.send(key=b"test-key", value=b"test-value", partition=7) + await asyncio.sleep(0) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + self.assertEqual( + spans[0].attributes[ + messaging_attributes.MESSAGING_DESTINATION_PARTITION_ID + ], + "7", + ) + + async def test_send_error(self) -> None: + app = _make_app() + error = RuntimeError("send failed") + producer = self._mock_producer(app, exception=error) + topic = app.topic("test-topic") + + with mock.patch.object( + app, "maybe_start_producer", mock.AsyncMock(return_value=producer) + ): + fut = await topic.send(key=b"test-key", value=b"test-value") + await asyncio.sleep(0) + with self.assertRaises(RuntimeError): + await fut + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.name, "test-topic send") + self.assertIs(span.status.status_code, StatusCode.ERROR) + self.assertEqual(len(span.events), 1) + self.assertEqual(span.events[0].name, "exception") + + async def test_stream_processing(self) -> None: + app = _make_app() + app.flow_control.resume() + channel = app.channel() + stream = channel.stream() + + remote_trace_id = 0x6E0C63257DE34C926F9EFCD03927272E + remote_span_id = 0x00F067AA0BA902B7 + traceparent = ( + f"00-{format_trace_id(remote_trace_id)}" + f"-{format_span_id(remote_span_id)}-01" + ) + await stream.channel.deliver( + self._message_factory( + 42, headers=[("traceparent", traceparent.encode())] + ) + ) + await stream.channel.deliver(self._message_factory(43)) + + iterator = aiter(stream) + try: + value = await anext(iterator) + self.assertEqual(value, b"test-value") + # while the event is being processed, the consumer span + # must be active in the current context + current_span = trace.get_current_span() + self.assertEqual( + current_span.get_span_context().trace_id, remote_trace_id + ) + # pulling the next event acks the first one and ends its span + await anext(iterator) + finally: + await stream.stop() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.name, "test-topic process") + self.assertEqual(span.kind, SpanKind.CONSUMER) + self.assertEqual(span.context.trace_id, remote_trace_id) + self.assertIsNotNone(span.parent) + self.assertEqual(span.parent.span_id, remote_span_id) + self._assert_common_attributes(span, app) + self.assertEqual( + span.attributes[messaging_attributes.MESSAGING_OPERATION_NAME], + "process", + ) + self.assertEqual( + span.attributes[messaging_attributes.MESSAGING_OPERATION_TYPE], + messaging_attributes.MessagingOperationTypeValues.PROCESS.value, + ) + self.assertEqual( + span.attributes[ + messaging_attributes.MESSAGING_DESTINATION_PARTITION_ID + ], + "1", + ) + self.assertEqual( + span.attributes[ + messaging_attributes.MESSAGING_CONSUMER_GROUP_NAME + ], + app.conf.id, + ) + self.assertEqual( + span.attributes[ + messaging_attributes.MESSAGING_KAFKA_MESSAGE_OFFSET + ], + 42, + ) + self.assertEqual( + span.attributes[messaging_attributes.MESSAGING_MESSAGE_ID], + "test-topic.1.42", + ) + self.assertEqual( + span.attributes[messaging_attributes.MESSAGING_KAFKA_MESSAGE_KEY], + "test-key", + ) + + async def test_send_from_stream_continues_trace(self) -> None: + app = _make_app() + app.flow_control.resume() + producer = self._mock_producer(app) + channel = app.channel() + stream = channel.stream() + topic = app.topic("test-topic") + + remote_trace_id = 0x6E0C63257DE34C926F9EFCD03927272E + traceparent = ( + f"00-{format_trace_id(remote_trace_id)}" + f"-{format_span_id(0x00F067AA0BA902B7)}-01" + ) + await stream.channel.deliver( + self._message_factory( + 42, headers=[("traceparent", traceparent.encode())] + ) + ) + await stream.channel.deliver(self._message_factory(43)) + + iterator = aiter(stream) + try: + await anext(iterator) + with mock.patch.object( + app, + "maybe_start_producer", + mock.AsyncMock(return_value=producer), + ): + # message sent while processing an event: the producer + # span must be a child of the consumer span + await topic.send( + key=b"test-key", value=b"test-value", force=True + ) + await asyncio.sleep(0) + await anext(iterator) + finally: + await stream.stop() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + send_span = next( + span for span in spans if span.name == "test-topic send" + ) + process_span = next( + span for span in spans if span.name == "test-topic process" + ) + self.assertEqual(send_span.context.trace_id, remote_trace_id) + self.assertEqual( + send_span.parent.span_id, process_span.context.span_id + ) + + async def test_hooks(self) -> None: + FaustInstrumentor().uninstrument() + produce_hook = mock.Mock() + process_hook = mock.Mock() + FaustInstrumentor().instrument( + tracer_provider=self.tracer_provider, + produce_hook=produce_hook, + process_hook=process_hook, + ) + + app = _make_app() + app.flow_control.resume() + producer = self._mock_producer(app) + topic = app.topic("test-topic") + channel = app.channel() + stream = channel.stream() + + with mock.patch.object( + app, "maybe_start_producer", mock.AsyncMock(return_value=producer) + ): + await topic.send(key=b"test-key", value=b"test-value") + await asyncio.sleep(0) + produce_hook.assert_called_once() + + await stream.channel.deliver(self._message_factory(42)) + iterator = aiter(stream) + try: + await anext(iterator) + finally: + await stream.stop() + process_hook.assert_called_once() + + async def test_uninstrumented_app_produces_no_spans(self) -> None: + app = _make_app() + FaustInstrumentor().uninstrument_app(app) + app.flow_control.resume() + producer = self._mock_producer(app) + topic = app.topic("test-topic") + channel = app.channel() + stream = channel.stream() + + with mock.patch.object( + app, "maybe_start_producer", mock.AsyncMock(return_value=producer) + ): + await topic.send(key=b"test-key", value=b"test-value") + await asyncio.sleep(0) + + await stream.channel.deliver(self._message_factory(42)) + iterator = aiter(stream) + try: + await anext(iterator) + finally: + await stream.stop() + + self.assertEqual(len(self.memory_exporter.get_finished_spans()), 0) diff --git a/opentelemetry-contrib-instrumentations/pyproject.toml b/opentelemetry-contrib-instrumentations/pyproject.toml index 6a5f5d3b1b..9ee6c4f94e 100644 --- a/opentelemetry-contrib-instrumentations/pyproject.toml +++ b/opentelemetry-contrib-instrumentations/pyproject.toml @@ -50,6 +50,7 @@ dependencies = [ "opentelemetry-instrumentation-exceptions==0.66b0.dev", "opentelemetry-instrumentation-falcon==0.66b0.dev", "opentelemetry-instrumentation-fastapi==0.66b0.dev", + "opentelemetry-instrumentation-faust==0.66b0.dev", "opentelemetry-instrumentation-flask==0.66b0.dev", "opentelemetry-instrumentation-grpc==0.66b0.dev", "opentelemetry-instrumentation-httpx==0.66b0.dev", diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py index f7f53ca0fb..4982825481 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/bootstrap_gen.py @@ -89,6 +89,10 @@ "library": "fastapi ~= 0.92", "instrumentation": "opentelemetry-instrumentation-fastapi==0.66b0.dev", }, + { + "library": "faust-streaming >= 0.10, < 1.0", + "instrumentation": "opentelemetry-instrumentation-faust==0.66b0.dev", + }, { "library": "flask >= 1.0", "instrumentation": "opentelemetry-instrumentation-flask==0.66b0.dev", diff --git a/pyproject.toml b/pyproject.toml index 4c90657e1c..1759796b98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "opentelemetry-instrumentation-django[instruments]", "opentelemetry-instrumentation-falcon[instruments]", "opentelemetry-instrumentation-fastapi[instruments]", + "opentelemetry-instrumentation-faust[instruments]", "opentelemetry-instrumentation-flask[instruments]", "opentelemetry-instrumentation-grpc[instruments]", "opentelemetry-instrumentation-httpx[instruments-any]", @@ -105,6 +106,7 @@ opentelemetry-instrumentation-dbapi = { workspace = true } opentelemetry-instrumentation-django = { workspace = true } opentelemetry-instrumentation-falcon = { workspace = true } opentelemetry-instrumentation-fastapi = { workspace = true } +opentelemetry-instrumentation-faust = { workspace = true } opentelemetry-instrumentation-flask = { workspace = true } opentelemetry-instrumentation-grpc = { workspace = true } opentelemetry-instrumentation-httpx = { workspace = true } diff --git a/tox.ini b/tox.ini index 81ba52d1f5..9d451cca16 100644 --- a/tox.ini +++ b/tox.ini @@ -153,6 +153,10 @@ envlist = pypy3-test-instrumentation-fastapi-{oldest,latest} lint-instrumentation-fastapi + ; opentelemetry-instrumentation-faust + py3{10,11,12}-test-instrumentation-faust + lint-instrumentation-faust + ; opentelemetry-instrumentation-flask ; The numbers at the end of the environment names ; below mean these dependencies are being used: @@ -632,6 +636,9 @@ deps = fastapi-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-fastapi/test-requirements.oldest.txt fastapi-{latest,lint}: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-fastapi/test-requirements.latest.txt + faust: {[testenv]test_deps} + faust: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-faust/test-requirements.txt + mysql: {[testenv]test_deps} mysql-0: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-mysql/test-requirements-0.txt mysql-1: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-mysql/test-requirements-1.txt @@ -888,6 +895,9 @@ commands = test-instrumentation-fastapi: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-fastapi/tests {posargs} lint-instrumentation-fastapi: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-fastapi" + test-instrumentation-faust: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-faust/tests {posargs} + lint-instrumentation-faust: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-faust" + test-instrumentation-flask: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-flask/tests {posargs} lint-instrumentation-flask: sh -c "cd instrumentation && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-flask" diff --git a/uv.lock b/uv.lock index adc4215353..469661027a 100644 --- a/uv.lock +++ b/uv.lock @@ -39,6 +39,7 @@ members = [ "opentelemetry-instrumentation-exceptions", "opentelemetry-instrumentation-falcon", "opentelemetry-instrumentation-fastapi", + "opentelemetry-instrumentation-faust", "opentelemetry-instrumentation-flask", "opentelemetry-instrumentation-google-genai", "opentelemetry-instrumentation-grpc", @@ -261,6 +262,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, ] +[[package]] +name = "aiohttp-cors" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/d89e846a5444b3d5eb8985a6ddb0daef3774928e1bfbce8e84ec97b0ffa7/aiohttp_cors-0.8.1.tar.gz", hash = "sha256:ccacf9cb84b64939ea15f859a146af1f662a6b1d68175754a07315e305fb1403", size = 38626, upload-time = "2025-03-31T14:16:20.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/3b/40a68de458904bcc143622015fff2352b6461cd92fd66d3527bf1c6f5716/aiohttp_cors-0.8.1-py3-none-any.whl", hash = "sha256:3180cf304c5c712d626b9162b195b1db7ddf976a2a25172b35bb2448b890a80d", size = 25231, upload-time = "2025-03-31T14:16:18.478Z" }, +] + [[package]] name = "aioitertools" version = "0.13.0" @@ -898,14 +911,14 @@ wheels = [ [[package]] name = "click" -version = "8.4.2" +version = "8.1.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, ] [[package]] @@ -954,6 +967,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorlog" +version = "6.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/61/f083b5ac52e505dfc1c624eafbf8c7589a0d7f32daa398d2e7590efa5fda/colorlog-6.10.1.tar.gz", hash = "sha256:eb4ae5cb65fe7fec7773c2306061a8e63e02efc2c72eba9d27b0fa23c94f1321", size = 17162, upload-time = "2025-10-16T16:14:11.978Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, +] + [[package]] name = "confluent-kafka" version = "2.15.0" @@ -1100,6 +1125,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/90/20d1747255f1ee69a412e319da51ea594c18cca195e7a4d4c713f045eff5/cramjam-2.11.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6c2eea545fef1065c7dd4eda991666fd9c783fbc1d226592ccca8d8891c02f23", size = 1714982, upload-time = "2025-07-27T21:25:05.79Z" }, ] +[[package]] +name = "croniter" +version = "2.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "pytz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/70/240ea4efb2e03b513d820e8da48b784258cbc88770b4b061931cf0269cf8/croniter-2.0.7.tar.gz", hash = "sha256:1041b912b4b1e03751a0993531becf77851ae6e8b334c9c76ffeffb8f055f53f", size = 49655, upload-time = "2024-07-16T15:21:23.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/1b/62e5ef33bb8cd599004b85f1ae648aaaf329ec396a8e210906e3f04df66a/croniter-2.0.7-py2.py3-none-any.whl", hash = "sha256:f15e80828d23920c4bb7f4d9340b932c9dcabecafc7775703c8b36d1253ed526", size = 21201, upload-time = "2024-07-16T15:21:20.433Z" }, +] + [[package]] name = "cryptography" version = "49.0.0" @@ -1318,6 +1356,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615", size = 129271, upload-time = "2026-06-29T12:44:13.905Z" }, ] +[[package]] +name = "faust-streaming" +version = "0.11.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-cors" }, + { name = "aiokafka" }, + { name = "click" }, + { name = "croniter" }, + { name = "intervaltree" }, + { name = "mode-streaming" }, + { name = "mypy-extensions" }, + { name = "opentracing" }, + { name = "six" }, + { name = "terminaltables" }, + { name = "venusian" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/f6/62d1c034a246178fafaa4aafb51d823a5e3103149120bcec731f0ea0bfdb/faust-streaming-0.11.3.tar.gz", hash = "sha256:3488be5dec17dcd4613dfa2600bb2bf83ebadb05de4620dca791ed4a0b7bd650", size = 759181, upload-time = "2024-08-23T14:05:00.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/c4/03dea4f5b06a4a13a0d138991605693ac89c562f388f5e8eba834c2ec1bf/faust_streaming-0.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ce60311f1130351c86ffe42139462f182ac308b744a37f879e336c31e9661daa", size = 517811, upload-time = "2024-08-23T14:04:39.832Z" }, + { url = "https://files.pythonhosted.org/packages/d6/75/cfab604fb8853e56df1d78c1925770a9145420ba902c8b1528f9337d9873/faust_streaming-0.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bd06c9f97727032a90597f92c0f06514286011c47038c1b606005449b0526033", size = 514641, upload-time = "2024-08-23T14:04:41.673Z" }, + { url = "https://files.pythonhosted.org/packages/ad/61/edb75b1771b36855b955e696dbae2b7929e48ed7156d495a937b1d3130d3/faust_streaming-0.11.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3b112b1642c2ae952600eb1b5d873d1cb66ee568019ddd9ede6bd1e1afb15e3", size = 1160475, upload-time = "2024-08-23T14:04:43.593Z" }, + { url = "https://files.pythonhosted.org/packages/71/45/ac219344d5b48ab8dae05a8179dfcf9ca1d62c71d3c658f7b30e4f60d5bf/faust_streaming-0.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:115d274ec3366a2b03ee85fd9b25aeeaa8389ae77dd8d029559a99e8e7a401cb", size = 518089, upload-time = "2024-08-23T14:04:45.286Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5b/b2251f781db100676ed5efe79714ccf31acab8c83a19eb91eb431f1d7b27/faust_streaming-0.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3b26283253f94b8d6e73f7184e2b91dd33c3661a35ef0111efed6e403f54f54", size = 514663, upload-time = "2024-08-23T14:04:46.712Z" }, + { url = "https://files.pythonhosted.org/packages/86/d0/3cef9ab2cbda3223fa920909e1ec135f334283343a0ef42a58cf56080735/faust_streaming-0.11.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0eb3438bc05e77446ce5e8c0ed0929d983a3f29e4db12a58c084416041c2aea", size = 1220386, upload-time = "2024-08-23T14:04:48.257Z" }, +] + [[package]] name = "filelock" version = "3.29.4" @@ -1712,14 +1779,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/a1/1f7f0c555f5858fd2906fe9f7b0a3554fddb85cb70df7a6aaec41dc292c2/greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c", size = 285838, upload-time = "2026-06-26T18:21:05.167Z" }, { url = "https://files.pythonhosted.org/packages/0a/29/be9f43ed61677a5759b38c8a9389248133c8c731bbfc0574ecdff66c99fc/greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04", size = 602342, upload-time = "2026-06-26T19:07:06.908Z" }, { url = "https://files.pythonhosted.org/packages/b9/42/ba41c97ec36aa4b3ec25e5aa691d79561254805fad7f2f826dd6770587e2/greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce", size = 615541, upload-time = "2026-06-26T19:10:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/2e/8c/231ca675b0df779816950ca66b40b1fa14dbff4a0ed9814a9a29ec399140/greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8", size = 622473, upload-time = "2026-06-26T19:24:12.786Z" }, { url = "https://files.pythonhosted.org/packages/f5/c7/28747042e1df8a9cd120a1ebe15529fc4be3b486e13e8d551ff307a82412/greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec", size = 615675, upload-time = "2026-06-26T18:32:14.444Z" }, + { url = "https://files.pythonhosted.org/packages/81/fe/dd97c483a3ff82849196ccd07851600edd3ac9de74669ca8a6022ada9ea1/greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71", size = 418421, upload-time = "2026-06-26T19:25:34.503Z" }, { url = "https://files.pythonhosted.org/packages/cc/a8/b85525a6c8fba9f009a5f7c8df1545de8fb0f0bf3e0179194ef4e500317f/greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8", size = 1575057, upload-time = "2026-06-26T19:09:00.264Z" }, { url = "https://files.pythonhosted.org/packages/03/79/fb76edb218fe6735ab0edeba176c7ab80df9618f7c02ce4208979f3ae7db/greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702", size = 1641692, upload-time = "2026-06-26T18:31:41.454Z" }, { url = "https://files.pythonhosted.org/packages/6b/79/86fe3ee50ed55d9b3907eecd3208b5c3fe8a79515519aae98b4753c3fa1d/greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db", size = 238742, upload-time = "2026-06-26T18:20:40.758Z" }, { url = "https://files.pythonhosted.org/packages/51/58/5404031044f55afad7aad1aff8be3f22b1bed03e237cfeabbc7e5c8cfde0/greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4", size = 287424, upload-time = "2026-06-26T18:20:31.469Z" }, { url = "https://files.pythonhosted.org/packages/b4/bf/1c65e9b94a54d547068fa5b5a8a06f221f3316b48908e08668d29c77cb50/greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc", size = 606523, upload-time = "2026-06-26T19:07:08.859Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/b66baacc95775ad511287acb0137b95574a9ce5491902372b7564799d790/greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6", size = 618315, upload-time = "2026-06-26T19:10:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a0/68afd1ebad40db87dac0a28ffa120726b98bf9c7c40c481b0f63c105d298/greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb", size = 626155, upload-time = "2026-06-26T19:24:14.44Z" }, { url = "https://files.pythonhosted.org/packages/78/2b/28ed29463522fdbe4c15b1f63922041626a7478316b34ab4adda3f0a4aba/greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7", size = 617381, upload-time = "2026-06-26T18:32:16.077Z" }, + { url = "https://files.pythonhosted.org/packages/07/7f/e327d912239ec4b3b49999e3967389bcf1ee8722b9ee9194d2752ecd558a/greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c", size = 421083, upload-time = "2026-06-26T19:25:35.804Z" }, { url = "https://files.pythonhosted.org/packages/2a/7b/ad04e9d1337fc04965dc9fc616b6a72cb65a24b800a014c011ec812f5489/greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8", size = 1577771, upload-time = "2026-06-26T19:09:01.537Z" }, { url = "https://files.pythonhosted.org/packages/d8/33/6c87ab7ba663f70ca21f3022aad1ffe56d3f3e0521e836c2415e13abcc3c/greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8", size = 1644048, upload-time = "2026-06-26T18:31:42.996Z" }, { url = "https://files.pythonhosted.org/packages/1c/35/f0d8ee998b422cf8693b270f098e55d8d4ec8006b061b333f54f177d28d9/greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7", size = 239137, upload-time = "2026-06-26T18:23:21.664Z" }, @@ -1727,7 +1798,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/6e/4c37d51a2b7f82d2ff11bb6b5f7d766d9a011726624af255e843727627a3/greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2", size = 288685, upload-time = "2026-06-26T18:22:08.977Z" }, { url = "https://files.pythonhosted.org/packages/7a/73/815dd90131c1b71ebdf53dbc7c276cafec2a1173b97559f97aba72724a87/greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b", size = 604761, upload-time = "2026-06-26T19:07:10.114Z" }, { url = "https://files.pythonhosted.org/packages/9f/57/079cfe76bcef36b153b25607ee91c6fcb58f17f8b23c86bbbeabe0c88d72/greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab", size = 617044, upload-time = "2026-06-26T19:10:07.25Z" }, + { url = "https://files.pythonhosted.org/packages/fb/fb/d97dc261209c80744b7c8132693a30d70ec6e7315e632cb0a10b3fec94dd/greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23", size = 622351, upload-time = "2026-06-26T19:24:16.32Z" }, { url = "https://files.pythonhosted.org/packages/37/87/b4d095775a3fb1bcafbb483fc206b27ebb785724c83051447737085dc54e/greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861", size = 614244, upload-time = "2026-06-26T18:32:17.594Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/e5fee13cbbd0e8de312d9a146584b8a51891c68847330ef9dc8b5109d23f/greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c", size = 425395, upload-time = "2026-06-26T19:25:37.144Z" }, { url = "https://files.pythonhosted.org/packages/8a/70/7559b609683650fa2b95b8ab84b4ab0b26556a635d19675e12aa832d826d/greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149", size = 1574210, upload-time = "2026-06-26T19:09:03.077Z" }, { url = "https://files.pythonhosted.org/packages/ae/73/be55392074c60fc37655ca40fa6022457bfbf6718e9e342a7b0b41f96dd2/greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea", size = 1638627, upload-time = "2026-06-26T18:31:44.748Z" }, { url = "https://files.pythonhosted.org/packages/14/40/c57489acf8e37d74e2913d4eff63aa0dba17acccc4bdeef874dde2dbbec9/greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c", size = 239882, upload-time = "2026-06-26T18:23:27.518Z" }, @@ -1735,7 +1808,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/ff/a620267401db30a50cc8450ee90730e2d4a85658c055c0e760d4ed47fb13/greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550", size = 287609, upload-time = "2026-06-26T18:21:14.724Z" }, { url = "https://files.pythonhosted.org/packages/d6/fa/5401ac78021c826a25b6dde0c705e0a8f29b617509f9185a31dac15fbe1b/greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5", size = 607435, upload-time = "2026-06-26T19:07:11.412Z" }, { url = "https://files.pythonhosted.org/packages/e9/76/1dc144a2e56e65d36405078ed774224375ea520a1870a6e46e08bb4ac7bf/greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3", size = 619787, upload-time = "2026-06-26T19:10:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/61/2f5b1adf256d039f5dab8005de8d3d7ad2b0070a3219c0e036b3fbfeb440/greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1", size = 625580, upload-time = "2026-06-26T19:24:18.344Z" }, { url = "https://files.pythonhosted.org/packages/bf/87/c298cee62df1de4ad7fec32abda73526cff347fd143a6ed4ac369246668a/greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a", size = 616786, upload-time = "2026-06-26T18:32:19.128Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d9/ab7fc9e543e44d6879b0a6ef9a4b2188940fd180cc65d6f646883ddf7201/greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda", size = 427933, upload-time = "2026-06-26T19:25:38.219Z" }, { url = "https://files.pythonhosted.org/packages/9e/2e/e6f009885ed0705ccf33fe0583c117cfd03cde77e31a596dd5785a30762b/greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb", size = 1574316, upload-time = "2026-06-26T19:09:04.273Z" }, { url = "https://files.pythonhosted.org/packages/ef/fe/43fd110b01e40da0adb7c90ac7ea744bef2d43dca00de5095fd2351c2a68/greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b", size = 1638614, upload-time = "2026-06-26T18:31:46.297Z" }, { url = "https://files.pythonhosted.org/packages/0f/7c/062447147a61f8b4337b156fe70d32a165fcf2f89d7ca6255e572806705c/greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b", size = 239850, upload-time = "2026-06-26T18:21:54.613Z" }, @@ -1743,7 +1818,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/93/43e116ee114b28737ba7e12952a0d4e2f55944d0f84e42bc91ba7192a3c9/greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117", size = 288202, upload-time = "2026-06-26T18:23:49.604Z" }, { url = "https://files.pythonhosted.org/packages/82/2f/146d218299046a43d1f029fd544b3d110d0f175a09c715c7e8da4a4a345d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8", size = 654096, upload-time = "2026-06-26T19:07:12.71Z" }, { url = "https://files.pythonhosted.org/packages/a0/cc/04738cafb3f45fa991ea44f9de94c47dcec964f5a972300988a6751f49d9/greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d", size = 666304, upload-time = "2026-06-26T19:10:09.503Z" }, + { url = "https://files.pythonhosted.org/packages/86/a9/73fa62893d5b84b4205544e6b673c654cc43aa5b9899bac00f04d64af73d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814", size = 670657, upload-time = "2026-06-26T19:24:19.967Z" }, { url = "https://files.pythonhosted.org/packages/ce/aa/4e0dad5e605c270c784ab911c43da6adb136ccd4d81180f763ca429a723d/greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c", size = 663635, upload-time = "2026-06-26T18:32:20.802Z" }, + { url = "https://files.pythonhosted.org/packages/29/7e/2ffce64929fb3cab7b65d5a0b20aaf9764e227681d731b041077fc9a525a/greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260", size = 473497, upload-time = "2026-06-26T19:25:39.421Z" }, { url = "https://files.pythonhosted.org/packages/d1/50/13efdbea246fe3d3b735e191fec08fb50809f53cd2383ebe123d0809e44b/greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a", size = 1621252, upload-time = "2026-06-26T19:09:05.647Z" }, { url = "https://files.pythonhosted.org/packages/f7/22/c0a336ae4a1410fd5f5121098e5bfbf1865f64c5ef80b4b5412886c4a332/greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154", size = 1684824, upload-time = "2026-06-26T18:31:47.738Z" }, { url = "https://files.pythonhosted.org/packages/7a/94/91aec0030bea75c4b3244251d0de60a1f3432d1ecb53ab6c437fb5c3ba61/greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e", size = 240754, upload-time = "2026-06-26T18:22:15.669Z" }, @@ -1751,14 +1828,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/95/3e161213d7f1d378d15aa9e792093e9bfe01844680d04b7fd6e0107c9098/greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be", size = 296389, upload-time = "2026-06-26T18:22:20.657Z" }, { url = "https://files.pythonhosted.org/packages/00/92/715c44721abe2b4d1ae9abde4179411868a5bff312479f54e105d372f131/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310", size = 653382, upload-time = "2026-06-26T19:07:14.209Z" }, { url = "https://files.pythonhosted.org/packages/a0/83/37a10372a1090a6624cca8e74c12df1a36c2dc36429ed0255b7fb1aeee23/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8", size = 659401, upload-time = "2026-06-26T19:10:10.876Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/8faec206b851c22b1733545fda900829a1f3f5b1c78ae7e0fb3dba57d9f4/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d", size = 659582, upload-time = "2026-06-26T19:24:21.357Z" }, { url = "https://files.pythonhosted.org/packages/db/e2/d1509cad4207da559cc42986ecdd8fc67ad0d1bba2bf03023c467fd5e0f3/greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f", size = 656969, upload-time = "2026-06-26T18:32:22.272Z" }, + { url = "https://files.pythonhosted.org/packages/b4/55/50c19e49f8045834ada71ef12f8ad048eba8517c6aa41161bed676328fae/greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0", size = 491037, upload-time = "2026-06-26T19:25:40.672Z" }, { url = "https://files.pythonhosted.org/packages/86/7d/eaf70de20aadca3a5884aec58362861c64ce45e7b277f47ed026926a3b89/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21", size = 1617822, upload-time = "2026-06-26T19:09:06.893Z" }, { url = "https://files.pythonhosted.org/packages/8a/f9/414d38fc400ae4350d4185eaad1827676f7cf5287b9136e0ed1cbbe20a7f/greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da", size = 1677983, upload-time = "2026-06-26T18:31:49.396Z" }, { url = "https://files.pythonhosted.org/packages/e4/15/7edb977e08f9bff702fe42d6c902702786ff6b9694058b4e6a2a6ac90e57/greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3", size = 243626, upload-time = "2026-06-26T18:24:41.485Z" }, { url = "https://files.pythonhosted.org/packages/2c/8a/93928dce91e6b3598b5e779e8d1fd6576a504640c58e78627077f6a7a91a/greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc", size = 288860, upload-time = "2026-06-26T18:22:48.07Z" }, { url = "https://files.pythonhosted.org/packages/4f/ca/69db42d447a1378043e2c8f19c09cbbd1263371505053c496b49066d3d16/greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47", size = 659747, upload-time = "2026-06-26T19:07:15.565Z" }, { url = "https://files.pythonhosted.org/packages/a8/0b/af7ac2ef8dd41e3da1a40dda6305c23b9a03e13ba975ec916357b50f8575/greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81", size = 670419, upload-time = "2026-06-26T19:10:12.293Z" }, + { url = "https://files.pythonhosted.org/packages/25/aa/952cf28c2ff949a8c971134fb43854dd7eaa737218723aaef758f8c9aead/greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357", size = 674261, upload-time = "2026-06-26T19:24:22.79Z" }, { url = "https://files.pythonhosted.org/packages/51/1e/1d51640cacbfc455dbe9f9a9f594c49e4e244f63b9971a2f4764e46cc53d/greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d", size = 668787, upload-time = "2026-06-26T18:32:24.298Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f2/b00d6f5e63e531a93562b2ec1a4c320fbee91f580fc42e6417af69d706e5/greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128", size = 480322, upload-time = "2026-06-26T19:25:41.852Z" }, { url = "https://files.pythonhosted.org/packages/21/66/4030d5b0b5894500023f003bb054d9bb354dfbd1e186c3a296759172f5f5/greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34", size = 1626305, upload-time = "2026-06-26T19:09:08.281Z" }, { url = "https://files.pythonhosted.org/packages/0e/50/5221371c7550108dfa3c378debc41d032aa9c78e89abb01d8011cfc93289/greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b", size = 1688631, upload-time = "2026-06-26T18:31:51.278Z" }, { url = "https://files.pythonhosted.org/packages/68/5d/00d469daae3c65d2bf620b10eee82eb022127d483c6bc8c69fae6f3fbf17/greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930", size = 241027, upload-time = "2026-06-26T18:22:38.203Z" }, @@ -1766,7 +1847,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1c/da/4f4a8450962fad137c1c8981a3f1b8919d06c829993d4d476f9c525d5173/greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c", size = 297221, upload-time = "2026-06-26T18:23:27.176Z" }, { url = "https://files.pythonhosted.org/packages/57/66/b3bfae3e220a9b63ea539a0eea681800c69ab1aada757eae8789f183e7ce/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f", size = 657221, upload-time = "2026-06-26T19:07:16.973Z" }, { url = "https://files.pythonhosted.org/packages/7b/81/b6d4d73a709684fc77e7fa034d7c2fe82cffa9fc920fadcaa659c2626213/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2", size = 663226, upload-time = "2026-06-26T19:10:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/e9/39/0e0938a75115b939d42733a2a12e1d349653c9531fe6fe563e8a681f04e6/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91", size = 663706, upload-time = "2026-06-26T19:24:24.312Z" }, { url = "https://files.pythonhosted.org/packages/f5/07/e210b02b589f16e74ff48b730690e4a34ffe984219fce4f3c1a0e7ec8545/greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608", size = 660802, upload-time = "2026-06-26T18:32:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/5b/41/35d1c678cdb3c3b9e6bee691728e563cfb294202b23c7a4c3c2ccc343589/greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d", size = 498803, upload-time = "2026-06-26T19:25:43.063Z" }, { url = "https://files.pythonhosted.org/packages/eb/2e/5303eb3fa06bca089060f479707182a93e360683bc252acf846c3090d34e/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb", size = 1622157, upload-time = "2026-06-26T19:09:09.527Z" }, { url = "https://files.pythonhosted.org/packages/54/70/50de47a488f14df260b50ae34fb5d56016e308b098eab02c878b5223c26a/greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16", size = 1681159, upload-time = "2026-06-26T18:31:52.986Z" }, { url = "https://files.pythonhosted.org/packages/a7/13/1055e1dda7882073eda533e2b96c62e55bbd2db7fda6d5ece992febc7071/greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf", size = 244007, upload-time = "2026-06-26T18:22:04.353Z" }, @@ -1982,6 +2065,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "intervaltree" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/c3/b2afa612aa0373f3e6bb190e6de35f293b307d1537f109e3e25dbfcdf212/intervaltree-3.2.1.tar.gz", hash = "sha256:f3f7e8baeb7dd75b9f7a6d33cf3ec10025984a8e66e3016d537e52130c73cfe2", size = 1231531, upload-time = "2025-12-24T04:25:06.773Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/7f/8a80a1c7c2ed05822b5a2b312d2995f30c533641f8198366ba2e26a7bb03/intervaltree-3.2.1-py2.py3-none-any.whl", hash = "sha256:a8a8381bbd35d48ceebee932c77ffc988492d22fb1d27d0ba1d74a7694eb8f0b", size = 25929, upload-time = "2025-12-24T04:25:05.298Z" }, +] + [[package]] name = "iso8601" version = "2.1.0" @@ -2484,6 +2579,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mode-streaming" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorlog" }, + { name = "croniter" }, + { name = "mypy-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/e2/6cbe2bd0b41bf71ef6f574237d26642798ff58ba7d52c43d72a605969a73/mode_streaming-0.4.1-py2.py3-none-any.whl", hash = "sha256:9e773e1e7982165e64ba35e59acffb2916dd01f2a86e8eb174f9e95023ec370a", size = 94651, upload-time = "2024-04-15T13:28:26.615Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -2622,6 +2730,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "mysql-connector-python" version = "9.7.0" @@ -3434,6 +3551,31 @@ requires-dist = [ ] provides-extras = ["instruments"] +[[package]] +name = "opentelemetry-instrumentation-faust" +source = { editable = "instrumentation/opentelemetry-instrumentation-faust" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] + +[package.optional-dependencies] +instruments = [ + { name = "faust-streaming" }, +] + +[package.metadata] +requires-dist = [ + { name = "faust-streaming", marker = "extra == 'instruments'", specifier = ">=0.10,<1.0" }, + { name = "opentelemetry-api", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-api&branch=main" }, + { name = "opentelemetry-instrumentation", editable = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions", git = "https://github.com/open-telemetry/opentelemetry-python?subdirectory=opentelemetry-semantic-conventions&branch=main" }, + { name = "typing-extensions", specifier = "~=4.1" }, +] +provides-extras = ["instruments"] + [[package]] name = "opentelemetry-instrumentation-flask" source = { editable = "instrumentation/opentelemetry-instrumentation-flask" } @@ -4354,6 +4496,7 @@ dependencies = [ { name = "opentelemetry-instrumentation-django", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-falcon", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-fastapi", extra = ["instruments"] }, + { name = "opentelemetry-instrumentation-faust", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-flask", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-grpc", extra = ["instruments"] }, { name = "opentelemetry-instrumentation-httpx", extra = ["instruments-any"] }, @@ -4427,6 +4570,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-django", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-django" }, { name = "opentelemetry-instrumentation-falcon", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-falcon" }, { name = "opentelemetry-instrumentation-fastapi", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-faust", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-faust" }, { name = "opentelemetry-instrumentation-flask", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-flask" }, { name = "opentelemetry-instrumentation-grpc", extras = ["instruments"], editable = "instrumentation/opentelemetry-instrumentation-grpc" }, { name = "opentelemetry-instrumentation-httpx", extras = ["instruments-any"], editable = "instrumentation/opentelemetry-instrumentation-httpx" }, @@ -4539,6 +4683,12 @@ provides-extras = ["test", "upload"] name = "opentelemetry-util-http" source = { editable = "util/opentelemetry-util-http" } +[[package]] +name = "opentracing" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/28/2dba4e3efb64cc59d4311081a5ddad1dde20a19b69cd0f677cdb2f2c29a6/opentracing-2.4.0.tar.gz", hash = "sha256:a173117e6ef580d55874734d1fa7ecb6f3655160b8b8974a2a1e98e5ec9c840d", size = 46228, upload-time = "2020-11-19T17:10:42.862Z" } + [[package]] name = "orjson" version = "3.11.9" @@ -6065,6 +6215,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sqlalchemy" version = "2.0.51" @@ -6173,6 +6332,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "terminaltables" +version = "3.1.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/fc/0b73d782f5ab7feba8d007573a3773c58255f223c5940a7b7085f02153c3/terminaltables-3.1.10.tar.gz", hash = "sha256:ba6eca5cb5ba02bba4c9f4f985af80c54ec3dccf94cfcd190154386255e47543", size = 12264, upload-time = "2021-12-07T19:03:35.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/fb/ea621e0a19733e01fe4005d46087d383693c0f4a8f824b47d8d4122c87e0/terminaltables-3.1.10-py2.py3-none-any.whl", hash = "sha256:e4fdc4179c9e4aab5f674d80f09d76fa436b96fdc698a8505e0a36bf0804a874", size = 15155, upload-time = "2021-12-07T19:03:34.013Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -6574,11 +6742,11 @@ wheels = [ [[package]] name = "venusian" -version = "3.1.1" +version = "3.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/70/4c/eefa68085c555dc11e6744b9c6fbe5966b1c9378c47267776a448923e9a5/venusian-3.1.1.tar.gz", hash = "sha256:534fb3b355669283eb3954581931e5d1d071fce61d029d58f3219a5e3a6f0c41", size = 39269, upload-time = "2024-12-02T02:35:04.033Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/39/7c0d9011ec465951aaf71c252effc7c031a04404887422c6f66ba26500e1/venusian-3.1.0.tar.gz", hash = "sha256:eb72cdca6f3139a15dc80f9c95d3c10f8a54a0ba881eeef8e2ec5b42d3ee3a95", size = 37960, upload-time = "2023-11-06T13:13:46.277Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/4b/34d926eba40db81b204066a60b4efdc5d8867a8efcbfe44d69b634b1c907/venusian-3.1.1-py3-none-any.whl", hash = "sha256:0845808a985976acbceaa1fbb871c7fac4fb28ae75453232970e9c2c2866dbf4", size = 14026, upload-time = "2024-12-02T02:35:03.056Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d7/36860f68eb977ad685d0f0fda733eca913dbda1bb29bbc5f1c5ba460201a/venusian-3.1.0-py3-none-any.whl", hash = "sha256:d1fb1e49927f42573f6c9b7c4fcf61c892af8fdcaa2314daa01d9a560b23488d", size = 13987, upload-time = "2023-11-06T13:13:44.358Z" }, ] [[package]]