diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..dfdb8b771 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.gitignore b/.gitignore index be55c9072..bea5d48c6 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,7 @@ MIGRATION_REPORT.md # dev sample projects dev/uv.lock +# Local secrets (never commit) +.env +.env.* +!.env.example diff --git a/dev-requirements.txt b/dev-requirements.txt index f39d29949..a2826b8c6 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,7 +1,7 @@ pyright==1.1.411 -sphinx==9.1.0 -sphinx-rtd-theme==3.1.0 -sphinx-autodoc-typehints==3.12.1 +sphinx<9.0.0 +sphinx-rtd-theme +sphinx-autodoc-typehints pytest==9.1.1 pytest-cov==7.1.0 readme-renderer==45.0 diff --git a/docs-requirements.txt b/docs-requirements.txt index 20e305245..ee66b4381 100644 --- a/docs-requirements.txt +++ b/docs-requirements.txt @@ -1,6 +1,6 @@ -sphinx==9.1.0 -sphinx-rtd-theme==3.1.0 -sphinx-autodoc-typehints==3.12.1 +sphinx<9.0.0 +sphinx-rtd-theme +sphinx-autodoc-typehints # Required by opentelemetry-util-genai fsspec>=2026.7.0 diff --git a/instrumentation/README.md b/instrumentation/README.md index 800e69732..2080c99fd 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -4,6 +4,7 @@ | [opentelemetry-instrumentation-genai-agno](./opentelemetry-instrumentation-genai-agno) | agno >= 2.0.0 | No | development | [opentelemetry-instrumentation-genai-anthropic](./opentelemetry-instrumentation-genai-anthropic) | anthropic >= 0.16.0 | No | development | [opentelemetry-instrumentation-genai-claude-agent-sdk](./opentelemetry-instrumentation-genai-claude-agent-sdk) | claude-agent-sdk >= 0.1.14 | No | development +| [opentelemetry-instrumentation-genai-haystack](./opentelemetry-instrumentation-genai-haystack) | haystack-ai >= 3.0.0 | No | development | [opentelemetry-instrumentation-genai-langchain](./opentelemetry-instrumentation-genai-langchain) | langchain >= 0.3.21 | No | development | [opentelemetry-instrumentation-genai-llama-index](./opentelemetry-instrumentation-genai-llama-index) | llama-index-core >= 0.14.19 | No | development | [opentelemetry-instrumentation-genai-openai](./opentelemetry-instrumentation-genai-openai) | openai >= 1.26.0 | Yes | development diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/.changelog/.gitignore b/instrumentation/opentelemetry-instrumentation-genai-haystack/.changelog/.gitignore new file mode 100644 index 000000000..f935021a8 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/.changelog/.gitignore @@ -0,0 +1 @@ +!.gitignore diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/.changelog/318.added b/instrumentation/opentelemetry-instrumentation-genai-haystack/.changelog/318.added new file mode 100644 index 000000000..3b637c1bf --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/.changelog/318.added @@ -0,0 +1 @@ +Add ``opentelemetry-instrumentation-genai-haystack``, migrated from ``openinference-instrumentation-haystack``, with support for pipeline, chat/embedding/retrieval, agent, and tool invocations built on ``opentelemetry-util-genai``. diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/LICENSE b/instrumentation/opentelemetry-instrumentation-genai-haystack/LICENSE new file mode 100644 index 000000000..e294301d4 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/LICENSE @@ -0,0 +1,202 @@ +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 Support. 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 The OpenTelemetry Authors + + 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-genai-haystack/README.rst b/instrumentation/opentelemetry-instrumentation-genai-haystack/README.rst new file mode 100644 index 000000000..042513f8d --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/README.rst @@ -0,0 +1,130 @@ +OpenTelemetry Haystack Instrumentation +======================================= + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-genai-haystack.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-genai-haystack/ + +This library allows tracing GenAI operations performed with the +`Haystack `_ Python framework: pipeline +execution, LLM generator calls, embedder calls, retriever/ranker calls, +``Agent`` runs, and tool calls. + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-genai-haystack + +Usage +----- + +.. code-block:: python + + from opentelemetry.instrumentation.genai.haystack import HaystackInstrumentor + + # Instrument Haystack + HaystackInstrumentor().instrument() + + # Use Haystack as normal + from haystack import Pipeline + from haystack.components.generators.chat.openai import OpenAIChatGenerator + from haystack.dataclasses import ChatMessage + + pipeline = Pipeline() + pipeline.add_component("llm", OpenAIChatGenerator(model="gpt-4o")) + pipeline.run({"llm": {"messages": [ChatMessage.from_user("Hello!")]}}) + +What gets instrumented +*********************** + +- ``Pipeline.run`` / ``Pipeline.run_async`` / ``Pipeline.run_async_generator`` + -- one ``invoke_workflow`` span per pipeline execution (never double-counted + when ``run_async_generator`` is driven internally by ``run_async``). +- Components classified as a generator, embedder, retriever/ranker, or + ``Agent`` -- one span per component ``run`` / ``run_async`` call. + Classification is a best-effort read of the component's class name and + ``run`` method type hints, since Haystack has no static component-kind + marker. Components that don't fall into one of these (prompt builders, + routers, converters, ...) are not wrapped: ``opentelemetry-util-genai`` + has no invocation type for a generic pipeline step. Component classes are + classified the instant they're registered (hooking the ``@component`` + decorator itself), so instrumenting before importing your pipeline's + components works correctly. +- ``haystack.tools.Tool.invoke`` / ``invoke_async`` -- one ``execute_tool`` + span per tool call, including calls an ``Agent`` drives internally. + +See ``tests/conformance/`` for the exact operations covered. + +Known limitations +***************** + +- ``gen_ai.response.id`` is not populated for real OpenAI-backed chat + generators: Haystack's own ``OpenAIChatGenerator`` does not copy the + provider response id into the reply's ``meta``, so it's only populated for + generators (or tests) that do include it there. +- ``server.address`` / ``server.port`` are only populated once a component's + underlying SDK client has been constructed. ``Pipeline.run()`` calls + ``warm_up()`` automatically, so this is available for pipeline-driven + calls; a component called standalone only gets it starting on its second + call, since nothing else triggers ``warm_up()`` first. +- ``gen_ai.tool.call.id`` correlation between a tool-call span and the + assistant reply's ``tool_calls`` entry is not populated: it would require + hooking Haystack's private + ``haystack.components.agents.tool_calling._make_context_bound_invoke``, + which this instrumentation deliberately avoids depending on. +- ``gen_ai.provider.name`` has no mapping for Hugging Face API + generators/embedders (their model string encodes the provider, but there's + no corresponding enum value yet); it's set to ``"unknown"`` for these. +- Only components classified as a generator, embedder, retriever/ranker, or + ``Agent`` are wrapped -- there's no ``opentelemetry-util-genai`` invocation + type for a generic pipeline step (prompt builders, routers, converters, + joiners, ...). +- Raw provider-format tool dicts passed via + ``generation_kwargs={"tools": [...]}}`` aren't converted to + ``ToolDefinition``\\ s -- only typed ``haystack.tools.Tool`` / ``Toolset`` + objects passed as the dedicated ``tools`` parameter are. +- Per-document embedded text/vectors aren't recorded -- ``EmbeddingInvocation`` + only carries aggregate request/response metadata. + +Configuration +------------- + +Capture Message Content +*********************** + +By default, prompts and completions are not captured. To capture message content, set the +environment variable ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` to one of +``NO_CONTENT``, ``SPAN_ONLY``, ``EVENT_ONLY``, or ``SPAN_AND_EVENT``: + +:: + + export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_AND_EVENT + + +Uploading prompts and completions +*********************************** + +Instead of recording message content inline, prompts and completions can be uploaded to external +storage via a completion hook. To enable the built-in upload hook, set: + +- ``OTEL_INSTRUMENTATION_GENAI_COMPLETION_HOOK=upload`` +- ``OTEL_INSTRUMENTATION_GENAI_UPLOAD_BASE_PATH`` to an ``fsspec``-compatible URI/path + (e.g. ``/path/to/prompts`` or ``gs://my_bucket``), and install the ``upload`` extra + (``pip install opentelemetry-util-genai[upload]``). + +A custom ``CompletionHook`` can also be passed programmatically, taking precedence over the +environment variable:: + + HaystackInstrumentor().instrument(completion_hook=my_hook) + + +References +---------- + +* `OpenTelemetry Project `_ +* `OpenTelemetry GenAI semantic conventions `_ +* `Haystack `_ +* `Haystack documentation `_ diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/examples/manual/main.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/examples/manual/main.py new file mode 100644 index 000000000..10284b317 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/examples/manual/main.py @@ -0,0 +1,40 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import os + +from haystack.components.generators.chat.openai import OpenAIChatGenerator +from haystack.dataclasses import ChatMessage + +from opentelemetry import trace +from opentelemetry.instrumentation.genai.haystack import HaystackInstrumentor +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import ( + ConsoleSpanExporter, + SimpleSpanProcessor, +) + +# 1. Setup OpenTelemetry +provider = TracerProvider() +provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) +trace.set_tracer_provider(provider) + +# 2. Instrument Haystack +# Set OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=True to capture message content +os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = "True" +HaystackInstrumentor().instrument() + + +# 3. Use Haystack +def main(): + generator = OpenAIChatGenerator(model="gpt-4o-mini") + messages = [ + ChatMessage.from_user("Tell me a quick joke about observability.") + ] + response = generator.run(messages=messages) + print("\nResponse:") + print(response["replies"][0].text) + + +if __name__ == "__main__": + main() diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/pyproject.toml b/instrumentation/opentelemetry-instrumentation-genai-haystack/pyproject.toml new file mode 100644 index 000000000..a92341875 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/pyproject.toml @@ -0,0 +1,92 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-genai-haystack" +dynamic = ["version"] +description = "OpenTelemetry Haystack 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", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "opentelemetry-api ~= 1.43", + "opentelemetry-instrumentation >= 0.64b0, <1", + "opentelemetry-semantic-conventions >= 0.64b0, <1", + "opentelemetry-util-genai >= 1.0b0, <2", +] + +[project.optional-dependencies] +instruments = ["haystack-ai >= 3.0.0"] + +[project.entry-points.opentelemetry_instrumentor] +haystack = "opentelemetry.instrumentation.genai.haystack:HaystackInstrumentor" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-genai/tree/main/instrumentation/opentelemetry-instrumentation-genai-haystack" +Repository = "https://github.com/open-telemetry/opentelemetry-python-genai" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/genai/haystack/version.py" + +[tool.hatch.build.targets.sdist] +include = ["/src", "/tests"] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +markers = [ + "conformance: GenAI semconv conformance scenario (run via the *-conformance tox envs)", +] + +[tool.towncrier] +directory = ".changelog" +filename = "CHANGELOG.md" +start_string = "\n" +template = "../../scripts/changelog_template.j2" +issue_format = "[#{issue}](https://github.com/open-telemetry/opentelemetry-python-genai/pull/{issue})" +wrap = true +issue_pattern = "^(\\d+)" + +[[tool.towncrier.type]] +directory = "added" +name = "Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "Fixed" +showcontent = true diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/__init__.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/__init__.py new file mode 100644 index 000000000..47fec1430 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/__init__.py @@ -0,0 +1,235 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +""" +OpenTelemetry Haystack Instrumentation +======================================= + +Instrumentation for the `Haystack `_ Python +framework. + +Usage +----- + +.. code-block:: python + + from opentelemetry.instrumentation.genai.haystack import HaystackInstrumentor + from haystack import Pipeline + from haystack.components.generators.chat.openai import OpenAIChatGenerator + from haystack.dataclasses import ChatMessage + + HaystackInstrumentor().instrument() + + pipeline = Pipeline() + pipeline.add_component("llm", OpenAIChatGenerator(model="gpt-4o")) + pipeline.run({"llm": {"messages": [ChatMessage.from_user("Hello!")]}}) + +What gets instrumented +----------------------- + +- ``Pipeline.run`` / ``Pipeline.run_async`` — one ``invoke_workflow`` span per + pipeline execution. +- Components classified as a generator (``chat`` / ``text_completion``), an + embedder (``embeddings``), a retriever/ranker (``retrieval``), or an + ``Agent`` (``invoke_agent``) — one span per component ``run`` / + ``run_async`` call, classified by class name and ``run`` method type + hints (Haystack has no static component-kind marker). Components that + don't fall into one of these (prompt builders, routers, converters, ...) + are not wrapped — there is no corresponding ``opentelemetry-util-genai`` + invocation type for a generic pipeline step. +- ``haystack.tools.Tool.invoke`` / ``invoke_async`` — one ``execute_tool`` + span per tool call. + +See ``tests/conformance/`` for the exact operations covered and the +package ``README.rst``'s "Known limitations" section for the full list of +gaps. + +Configuration +------------- + +Message content capture can be enabled by setting +``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` to ``NO_CONTENT``, +``SPAN_ONLY``, ``EVENT_ONLY``, or ``SPAN_AND_EVENT``. + +API +--- +""" + +from __future__ import annotations + +from typing import Any, Callable, Collection, Dict, Optional, Type + +from wrapt import wrap_function_wrapper + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.util.genai.completion_hook import load_completion_hook +from opentelemetry.util.genai.handler import TelemetryHandler + +from .component_types import ComponentType, get_component_type +from .package import _instruments +from .patch import ( + component_run, + component_run_async, + pipeline_run, + pipeline_run_async, + pipeline_run_async_generator, + tool_invoke, + tool_invoke_async, +) + + +class HaystackInstrumentor(BaseInstrumentor): + """An instrumentor for the Haystack framework.""" + + def __init__(self) -> None: + super().__init__() + self._handler: Optional[TelemetryHandler] = None + self._original_pipeline_run: Optional[Callable[..., Any]] = None + self._original_pipeline_run_async: Optional[Callable[..., Any]] = None + self._original_pipeline_run_async_generator: Optional[ + Callable[..., Any] + ] = None + self._original_component_register: Optional[Callable[..., Any]] = None + self._original_tool_invoke: Optional[Callable[..., Any]] = None + self._original_tool_invoke_async: Optional[Callable[..., Any]] = None + self._wrapped_component_classes: Dict[Type[Any], ComponentType] = {} + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs: Any) -> None: + handler = TelemetryHandler( + tracer_provider=kwargs.get("tracer_provider"), + meter_provider=kwargs.get("meter_provider"), + logger_provider=kwargs.get("logger_provider"), + completion_hook=kwargs.get("completion_hook") + or load_completion_hook(), + ) + self._handler = handler + + import haystack # pylint: disable=import-outside-toplevel # noqa: PLC0415 + from haystack.core.component.component import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + _Component, + component, + ) + + self._original_pipeline_run = haystack.Pipeline.run + wrap_function_wrapper(haystack.Pipeline, "run", pipeline_run(handler)) + + self._original_pipeline_run_async = haystack.Pipeline.run_async + wrap_function_wrapper( + haystack.Pipeline, "run_async", pipeline_run_async(handler) + ) + + self._original_pipeline_run_async_generator = ( + haystack.Pipeline.run_async_generator + ) + wrap_function_wrapper( + haystack.Pipeline, + "run_async_generator", + pipeline_run_async_generator(handler), + ) + + # Eagerly wrap every classified component already registered (i.e. + # already imported) at instrumentation time. + for _class_path, component_cls in list(component.registry.items()): + self._wrap_component_class(component_cls) + + # Components are frequently imported (and so registered) *after* + # `instrument()` runs -- and not every component is ever run through + # a Pipeline (e.g. a Haystack `Agent` calls its `chat_generator.run()` + # directly). `_Component._component` is the single method the + # `@component` decorator calls to validate and register a class, + # for every component regardless of how it's later invoked, so hook + # it to classify and wrap each component class the instant it's + # defined. + self._original_component_register = _Component._component + wrap_function_wrapper( + _Component, "_component", self._make_registration_wrapper() + ) + + from haystack.tools import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + Tool, + ) + + self._original_tool_invoke = Tool.invoke + wrap_function_wrapper(Tool, "invoke", tool_invoke(handler)) + self._original_tool_invoke_async = Tool.invoke_async + wrap_function_wrapper(Tool, "invoke_async", tool_invoke_async(handler)) + + def _wrap_component_class(self, component_cls: Type[Any]) -> None: + if component_cls in self._wrapped_component_classes: + return + component_type = get_component_type(component_cls) + self._wrapped_component_classes[component_cls] = component_type + if component_type is ComponentType.UNKNOWN: + return + handler = self._handler + assert handler is not None + if callable(getattr(component_cls, "run", None)): + wrap_function_wrapper( + component_cls, "run", component_run(handler, component_type) + ) + if callable(getattr(component_cls, "run_async", None)): + wrap_function_wrapper( + component_cls, + "run_async", + component_run_async(handler, component_type), + ) + + def _make_registration_wrapper(self) -> Callable[..., Any]: + def wrapper( + wrapped: Callable[..., Any], + instance: Any, # noqa: ARG001 + args: tuple[Any, ...], + kwargs: Dict[str, Any], + ) -> Any: + # `_Component._component` may rebuild the class it's given + # (e.g. to attach a generated `__repr__`), so classify and wrap + # the *returned* class, not the input one. + registered_cls = wrapped(*args, **kwargs) + self._wrap_component_class(registered_cls) + return registered_cls + + return wrapper + + def _uninstrument(self, **kwargs: Any) -> None: + import haystack # pylint: disable=import-outside-toplevel # noqa: PLC0415 + + if self._original_pipeline_run is not None: + unwrap(haystack.Pipeline, "run") + if self._original_pipeline_run_async is not None: + unwrap(haystack.Pipeline, "run_async") + if self._original_pipeline_run_async_generator is not None: + unwrap(haystack.Pipeline, "run_async_generator") + if self._original_component_register is not None: + from haystack.core.component.component import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + _Component, + ) + + unwrap(_Component, "_component") + if ( + self._original_tool_invoke is not None + or self._original_tool_invoke_async is not None + ): + from haystack.tools import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + Tool, + ) + + if self._original_tool_invoke is not None: + unwrap(Tool, "invoke") + if self._original_tool_invoke_async is not None: + unwrap(Tool, "invoke_async") + for ( + component_cls, + component_type, + ) in self._wrapped_component_classes.items(): + if component_type is ComponentType.UNKNOWN: + continue + if callable(getattr(component_cls, "run", None)): + unwrap(component_cls, "run") + if callable(getattr(component_cls, "run_async", None)): + unwrap(component_cls, "run_async") + self._wrapped_component_classes.clear() + self._handler = None diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/component_types.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/component_types.py new file mode 100644 index 000000000..c54d4adf2 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/component_types.py @@ -0,0 +1,163 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Best-effort classification of Haystack components into GenAI operations. + +Haystack exposes a single duck-typed ``Component`` protocol — there is no +built-in way to ask "is this an LLM?" Classification is inferred from the +component class name and the type hints on its ``run`` method (Haystack's +``@component`` decorator populates an ``_output_types_cache`` used here). + +Only classifications with a direct ``opentelemetry-util-genai`` invocation +type are recognized (``GENERATOR`` -> inference, ``EMBEDDER`` -> embedding, +``RANKER`` / ``RETRIEVER`` -> retrieval, ``AGENT`` -> agent invocation). +Components that don't match any of these (prompt builders, routers, +converters, joiners, ...) classify as ``UNKNOWN`` and are not wrapped: +there is no util-genai invocation type for a generic component step, and +inventing one here would violate the "telemetry only through +opentelemetry-util-genai public types" rule. +""" + +from __future__ import annotations + +from enum import Enum, auto +from typing import ( + Any, + Callable, + Dict, + Optional, + get_args, + get_origin, + get_type_hints, +) + + +class ComponentType(Enum): + GENERATOR = auto() + EMBEDDER = auto() + RANKER = auto() + RETRIEVER = auto() + AGENT = auto() + UNKNOWN = auto() + + +def get_component_run_method(component: Any) -> Optional[Callable[..., Any]]: + """Return the component's ``run`` method, if it has one.""" + if callable(run_method := getattr(component, "run", None)): + return run_method + return None + + +def _get_run_method_output_types( + run_method: Callable[..., Any], +) -> Optional[Dict[str, type]]: + """Read the ``@component(output_types=...)``-populated cache off ``run``. + + See https://github.com/deepset-ai/haystack/blob/main/haystack/core/component/component.py + """ + output_types_cache = getattr(run_method, "_output_types_cache", None) + if isinstance(output_types_cache, dict): + return {key: value.type for key, value in output_types_cache.items()} + return None + + +def _get_run_method_input_types( + run_method: Callable[..., Any], +) -> Optional[Dict[str, type]]: + try: + return get_type_hints(run_method) + except Exception: # pylint: disable=broad-except + # get_type_hints can raise on forward refs it can't resolve; treat as unknown. + return None + + +def _is_list_of_documents_type(type_hint: Any) -> bool: + from haystack import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + Document, + ) + + origin = get_origin(type_hint) + if origin is not list: + return False + args = get_args(type_hint) + return len(args) == 1 and args[0] is Document + + +def _has_generator_output_type(run_method: Callable[..., Any]) -> bool: + from haystack.dataclasses.chat_message import ( # pylint: disable=import-outside-toplevel # noqa: PLC0415 + ChatMessage, + ) + + output_types = _get_run_method_output_types(run_method) + if ( + output_types is None + or (replies := output_types.get("replies")) is None + ): + return False + return replies == list[ChatMessage] or replies == list[str] + + +def _has_ranker_io_types(run_method: Callable[..., Any]) -> bool: + input_types = _get_run_method_input_types(run_method) + output_types = _get_run_method_output_types(run_method) + if input_types is None or output_types is None: + return False + has_documents_param = _is_list_of_documents_type( + input_types.get("documents") + ) + outputs_documents = _is_list_of_documents_type( + output_types.get("documents") + ) + return has_documents_param and outputs_documents + + +def _has_retriever_io_types(run_method: Callable[..., Any]) -> bool: + """A retriever outputs ``List[Document]`` without taking documents as input. + + This also catches retrievers with no document input at all, e.g. + ``SerperDevWebSearch``, which produces documents from a search query. + """ + input_types = _get_run_method_input_types(run_method) + output_types = _get_run_method_output_types(run_method) + if input_types is None or output_types is None: + return False + has_documents_param = "documents" in input_types + outputs_documents = _is_list_of_documents_type( + output_types.get("documents") + ) + return not has_documents_param and outputs_documents + + +def get_component_type(component: Any) -> ComponentType: + """Classify a component, given either its class or an instance. + + Classification is wired up while walking the Haystack component + *class* registry (before any instance exists), so this must work off a + bare class: the type-hint/``_output_types_cache`` heuristics below are + class-level artifacts set by the ``@component`` decorator, so they work + identically either way -- only the class-name lookup needs to branch. + """ + is_class = isinstance(component, type) + component_name = ( + component.__name__ if is_class else component.__class__.__name__ + ) + run_method = ( + getattr(component, "run", None) + if is_class + else get_component_run_method(component) + ) + if not callable(run_method): + return ComponentType.UNKNOWN + if "Agent" in component_name: + return ComponentType.AGENT + if "Generator" in component_name or _has_generator_output_type(run_method): + return ComponentType.GENERATOR + if "Embedder" in component_name: + return ComponentType.EMBEDDER + if "Ranker" in component_name and _has_ranker_io_types(run_method): + return ComponentType.RANKER + if ( + "Retriever" in component_name or "WebSearch" in component_name + ) and _has_retriever_io_types(run_method): + return ComponentType.RETRIEVER + return ComponentType.UNKNOWN diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/message_utils.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/message_utils.py new file mode 100644 index 000000000..737b416dd --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/message_utils.py @@ -0,0 +1,164 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Maps Haystack ``ChatMessage`` / ``Document`` / ``Tool`` shapes onto the +``opentelemetry.util.genai.types`` message model. + +Reference: https://github.com/open-telemetry/semantic-conventions-genai/tree/main/docs/gen-ai +(``gen-ai-input-messages.json`` / ``gen-ai-output-messages.json`` / +``gen-ai-tool-definitions.json``). +""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +from opentelemetry.util.genai.types import ( + FunctionToolDefinition, + InputMessage, + MessagePart, + OutputMessage, + Text, + ToolCallRequest, + ToolCallResponse, + ToolDefinition, +) + +_FINISH_REASON_MAP = { + "stop": "stop", + "length": "length", + "content_filter": "content_filter", + "tool_calls": "tool_calls", + "tool_call": "tool_calls", +} + + +def _normalize_finish_reason(finish_reason: str | None) -> str: + if finish_reason is None: + return "stop" + return _FINISH_REASON_MAP.get(finish_reason, finish_reason) + + +def _chat_message_parts(message: Any) -> list[MessagePart]: + """Build the ``parts`` list for a single Haystack ``ChatMessage``.""" + parts: list[MessagePart] = [] + for tool_call in message.tool_calls or []: + parts.append( + ToolCallRequest( + name=tool_call.tool_name, + arguments=tool_call.arguments, + id=tool_call.id, + ) + ) + for tool_call_result in message.tool_call_results or []: + origin = tool_call_result.origin + parts.append( + ToolCallResponse( + response=tool_call_result.result, + id=origin.id if origin is not None else None, + ) + ) + text = message.text + if text is not None: + parts.append(Text(content=text)) + return parts + + +def to_input_message(message: Any) -> InputMessage: + """Convert a single Haystack ``ChatMessage`` into an ``InputMessage``.""" + return InputMessage( + role=message.role.value, parts=_chat_message_parts(message) + ) + + +def to_input_messages(messages: Sequence[Any]) -> list[InputMessage]: + """Convert a ``List[ChatMessage]`` (a ChatGenerator's ``messages`` argument).""" + return [to_input_message(message) for message in messages] + + +def prompt_to_input_messages(prompt: str) -> list[InputMessage]: + """Convert a plain-text ``Generator`` prompt string into a single ``InputMessage``.""" + return [InputMessage(role="user", parts=[Text(content=prompt)])] + + +def chat_replies_to_output_messages( + replies: Sequence[Any], +) -> list[OutputMessage]: + """Convert a ChatGenerator's ``replies: List[ChatMessage]`` into ``OutputMessage``\\ s.""" + output_messages: list[OutputMessage] = [] + for reply in replies: + meta = reply.meta if isinstance(reply.meta, dict) else {} + finish_reason = _normalize_finish_reason(meta.get("finish_reason")) + output_messages.append( + OutputMessage( + role=reply.role.value, + parts=_chat_message_parts(reply), + finish_reason=finish_reason, + ) + ) + return output_messages + + +def text_replies_to_output_messages( + replies: Sequence[str], +) -> list[OutputMessage]: + """Convert a text ``Generator``'s ``replies: List[str]`` into ``OutputMessage``\\ s.""" + return [ + OutputMessage( + role="assistant", parts=[Text(content=reply)], finish_reason="stop" + ) + for reply in replies + ] + + +def tool_to_definition(tool: Any) -> ToolDefinition: + """Convert a Haystack ``Tool`` into a ``FunctionToolDefinition``.""" + return FunctionToolDefinition( + name=tool.name, + description=tool.description, + parameters=tool.parameters, + ) + + +def tools_to_definitions(tools: Any) -> list[ToolDefinition] | None: + """Best-effort conversion of a ChatGenerator's ``tools`` argument. + + Only handles the typed ``haystack.tools.Tool`` / ``Toolset`` objects a + caller passes as the dedicated ``tools`` run parameter. Raw provider- + format tool dicts passed through ``generation_kwargs={"tools": [...]}}`` + are not typed Haystack objects and have no reliable common shape across + generators. + """ + if not tools: + return None + resolved = getattr(tools, "tools", tools) # Toolset -> underlying list + definitions: list[ToolDefinition] = [] + for tool in resolved: + name = getattr(tool, "name", None) + if name is None: + continue + definitions.append(tool_to_definition(tool)) + return definitions or None + + +def documents_to_retrieval_documents( + documents: Sequence[Any], +) -> list[Mapping[str, Any]]: + """Convert ``List[Document]`` into the ``gen_ai.retrieval.documents`` shape. + + The semconv ``RetrievalDocument`` model requires ``id`` and ``score`` and + allows extra properties (``ConfigDict(extra="allow")``); ``content`` is + passed through as one such extra field, matching what Haystack's + ``Document`` calls it. + """ + retrieval_documents: list[Mapping[str, Any]] = [] + for document in documents: + entry: dict[str, Any] = {} + if document.id is not None: + entry["id"] = document.id + if document.score is not None: + entry["score"] = document.score + if document.content is not None: + entry["content"] = document.content + retrieval_documents.append(entry) + return retrieval_documents diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/package.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/package.py new file mode 100644 index 000000000..b07bd2d17 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/package.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +_instruments = ("haystack-ai >= 3.0.0",) diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/patch.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/patch.py new file mode 100644 index 000000000..a499b733a --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/patch.py @@ -0,0 +1,586 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Patching functions for Haystack instrumentation. + +Builds ``opentelemetry-util-genai`` invocations around: + +- ``haystack.Pipeline.run`` / ``run_async`` -> ``WorkflowInvocation`` +- classified component ``run`` / ``run_async`` methods -> ``InferenceInvocation`` + (generators), ``EmbeddingInvocation`` (embedders), ``RetrievalInvocation`` + (retrievers/rankers), or ``AgentInvocation`` (``Agent``) +- ``haystack.tools.tool.Tool.invoke`` / ``invoke_async`` -> ``ToolInvocation`` + +See ``component_types.py`` for the classification and which components/methods +this migration deliberately doesn't wrap. +""" + +from __future__ import annotations + +from contextvars import ContextVar +from inspect import BoundArguments, Parameter, signature +from typing import Any, Callable, Mapping + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAI, +) +from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.invocation import ( + AgentInvocation, + EmbeddingInvocation, + InferenceInvocation, + RetrievalInvocation, + ToolInvocation, +) + +from .component_types import ComponentType +from .message_utils import ( + chat_replies_to_output_messages, + documents_to_retrieval_documents, + prompt_to_input_messages, + text_replies_to_output_messages, + to_input_messages, + tools_to_definitions, +) +from .provider import infer_provider + +CHAT = GenAI.GenAiOperationNameValues.CHAT.value +TEXT_COMPLETION = GenAI.GenAiOperationNameValues.TEXT_COMPLETION.value + + +def _bind_arguments( + func: Callable[..., Any], args: tuple[Any, ...], kwargs: Mapping[str, Any] +) -> BoundArguments: + sig = signature(func) + accepts_var_kwargs = any( + param.kind == Parameter.VAR_KEYWORD + for param in sig.parameters.values() + ) + valid_kwargs = { + key: value + for key, value in kwargs.items() + if accepts_var_kwargs or key in sig.parameters + } + bound = sig.bind_partial(*args, **valid_kwargs) + bound.apply_defaults() + return bound + + +# --------------------------------------------------------------------------- +# Pipeline.run / Pipeline.run_async / Pipeline.run_async_generator -> WorkflowInvocation +# --------------------------------------------------------------------------- +# +# Pipeline.run_async (the true async entry point) internally drains +# run_async_generator() to completion in the same asyncio task. Wrapping +# both unconditionally would double-count a single logical pipeline +# execution, so `_inside_run_async` -- set for the duration of the outer +# call -- lets the run_async_generator wrapper tell "called directly by +# user code" (create a span) from "driven internally by run_async" (already +# covered by the outer span; skip). contextvars propagate across `await` +# within one task, so this holds across the internal `async for`. + +_inside_run_async: ContextVar[bool] = ContextVar( + "_inside_run_async", default=False +) + + +def pipeline_run(handler: TelemetryHandler) -> Callable[..., Any]: + def traced_method( + wrapped: Callable[..., Any], + instance: Any, + args: tuple[Any, ...], + kwargs: Mapping[str, Any], + ) -> Any: + invocation = handler.workflow(name=instance.__class__.__name__) + try: + response = wrapped(*args, **kwargs) + except Exception as exc: + invocation.fail(exc) + raise + invocation.stop() + return response + + return traced_method + + +def pipeline_run_async(handler: TelemetryHandler) -> Callable[..., Any]: + async def traced_method( + wrapped: Callable[..., Any], + instance: Any, + args: tuple[Any, ...], + kwargs: Mapping[str, Any], + ) -> Any: + invocation = handler.workflow(name=instance.__class__.__name__) + token = _inside_run_async.set(True) + try: + response = await wrapped(*args, **kwargs) + except Exception as exc: + invocation.fail(exc) + raise + finally: + _inside_run_async.reset(token) + invocation.stop() + return response + + return traced_method + + +def pipeline_run_async_generator( + handler: TelemetryHandler, +) -> Callable[..., Any]: + async def traced_method( + wrapped: Callable[..., Any], + instance: Any, + args: tuple[Any, ...], + kwargs: Mapping[str, Any], + ) -> Any: + if _inside_run_async.get(): + # Driven internally by an already-wrapped run_async() call -- + # its WorkflowInvocation already covers this execution. + async for item in wrapped(*args, **kwargs): + yield item + return + invocation = handler.workflow(name=instance.__class__.__name__) + try: + async for item in wrapped(*args, **kwargs): + yield item + except Exception as exc: + invocation.fail(exc) + raise + finally: + invocation.stop() + + return traced_method + + +# --------------------------------------------------------------------------- +# Classified component run() / run_async() wrapping +# --------------------------------------------------------------------------- + + +def _server_address_and_port(component: Any) -> tuple[str | None, int | None]: + """Best-effort ``server.address``/``server.port`` from a component's SDK client. + + Haystack's OpenAI-backed generators/embedders construct their + underlying SDK client lazily via ``warm_up()`` (``self.client``/ + ``self.async_client`` start as ``None``). ``Pipeline.run()`` calls + ``warm_up()`` on its components automatically, so this resolves + correctly for Pipeline-driven calls; a component called *standalone* + only gets it starting on the instance's second call, since nothing + else triggers ``warm_up()`` first. + """ + client = getattr(component, "client", None) or getattr( + component, "async_client", None + ) + base_url = getattr(client, "base_url", None) + if base_url is None: + return None, None + return getattr(base_url, "host", None), getattr(base_url, "port", None) + + +def _start_generator_invocation( + handler: TelemetryHandler, + component: Any, + bound_arguments: BoundArguments, + capture_content: bool, +) -> tuple[InferenceInvocation, bool]: + """Start an InferenceInvocation for a Generator/ChatGenerator ``run``. + + Returns ``(invocation, is_chat)`` — ``is_chat`` selects how the response + is parsed once the wrapped call returns. + """ + arguments = bound_arguments.arguments + # ChatGenerator-style components take a ``messages`` parameter (a + # ``list[ChatMessage]`` or, on newer Haystack versions, a bare ``str`` + # convenience form) and always reply with ``list[ChatMessage]``. Plain + # text-completion generators take a ``prompt: str`` and reply with + # ``list[str]``. Presence of the parameter -- not its runtime type -- + # decides which shape the response will have. + is_chat = "messages" in arguments + operation_name = CHAT if is_chat else TEXT_COMPLETION + messages = arguments.get("messages") + request_model = getattr(component, "model", None) + server_address, server_port = _server_address_and_port(component) + invocation = handler.inference( + provider=infer_provider(component) or "unknown", + request_model=request_model, + operation_name=operation_name, + server_address=server_address, + server_port=server_port, + ) + if capture_content: + if is_chat and isinstance(messages, str): + invocation.input_messages = prompt_to_input_messages(messages) + elif is_chat: + invocation.input_messages = to_input_messages(messages) + elif isinstance(prompt := arguments.get("prompt"), str): + invocation.input_messages = prompt_to_input_messages(prompt) + tools = arguments.get("tools") + if tools: + invocation.tool_definitions = tools_to_definitions(tools) + generation_kwargs = arguments.get("generation_kwargs") or {} + if isinstance(generation_kwargs, Mapping): + if isinstance( + temperature := generation_kwargs.get("temperature"), (int, float) + ): + invocation.temperature = float(temperature) + if isinstance(top_p := generation_kwargs.get("top_p"), (int, float)): + invocation.top_p = float(top_p) + if isinstance(max_tokens := generation_kwargs.get("max_tokens"), int): + invocation.max_tokens = max_tokens + return invocation, is_chat + + +def _finish_generator_invocation( + invocation: InferenceInvocation, + is_chat: bool, + response: Mapping[str, Any], + capture_content: bool, +) -> None: + replies = response.get("replies") or [] + meta = response.get("meta") + + if capture_content: + if is_chat: + invocation.output_messages = chat_replies_to_output_messages( + replies + ) + else: + invocation.output_messages = text_replies_to_output_messages( + replies + ) + + reply_meta: Mapping[str, Any] | None = None + if isinstance(meta, list) and meta and isinstance(meta[0], Mapping): + reply_meta = meta[0] + elif is_chat and replies: + first_reply_meta = getattr(replies[0], "meta", None) + if isinstance(first_reply_meta, Mapping): + reply_meta = first_reply_meta + + if reply_meta is not None: + if isinstance(model := reply_meta.get("model"), str): + invocation.response_model_name = model + # Best-effort: Haystack's own OpenAIChatGenerator does not copy the + # provider response id into `reply.meta`, so this only populates for + # generators/tests that do. + if isinstance(response_id := reply_meta.get("id"), str): + invocation.response_id = response_id + usage = reply_meta.get("usage") + if isinstance(usage, Mapping): + if isinstance(prompt_tokens := usage.get("prompt_tokens"), int): + invocation.input_tokens = prompt_tokens + if isinstance( + completion_tokens := usage.get("completion_tokens"), int + ): + invocation.output_tokens = completion_tokens + finish_reason = reply_meta.get("finish_reason") + if isinstance(finish_reason, str): + invocation.finish_reasons = [finish_reason] + invocation.stop() + + +def _start_embedding_invocation( + handler: TelemetryHandler, + component: Any, + capture_content: bool, # noqa: ARG001 - kept for signature symmetry with generator/retrieval starters +) -> EmbeddingInvocation: + request_model = getattr(component, "model", None) + server_address, server_port = _server_address_and_port(component) + return handler.embedding( + provider=infer_provider(component) or "unknown", + request_model=request_model, + server_address=server_address, + server_port=server_port, + ) + + +def _finish_embedding_invocation( + invocation: EmbeddingInvocation, response: Mapping[str, Any] +) -> None: + documents = response.get("documents") + vector = None + if isinstance(documents, list) and documents: + vector = getattr(documents[0], "embedding", None) + else: + vector = response.get("embedding") + # Haystack embedders return the decoded vector as a `list[float]` or a + # `numpy.ndarray` depending on the provider integration; duck-type on + # `__len__` rather than requiring a specific sequence type. + if vector is not None and hasattr(vector, "__len__"): + invocation.dimension_count = len(vector) + + meta = response.get("meta") + if isinstance(meta, Mapping): + usage = meta.get("usage") + if isinstance(usage, Mapping) and isinstance( + prompt_tokens := usage.get("prompt_tokens"), int + ): + invocation.input_tokens = prompt_tokens + if isinstance(model := meta.get("model"), str): + invocation.response_model_name = model + invocation.stop() + + +def _start_retrieval_invocation( + handler: TelemetryHandler, + component: Any, + bound_arguments: BoundArguments, + capture_content: bool, +) -> RetrievalInvocation: + arguments = bound_arguments.arguments + invocation = handler.retrieval() + top_k = arguments.get("top_k") + if top_k is None: + top_k = getattr(component, "top_k", None) + if isinstance(top_k, (int, float)): + # gen_ai.request.top_k is registered as an int; Haystack's own + # top_k parameters are always a plain int count of results, so + # round-trip it as one rather than widening to float. + invocation.top_k = int(top_k) + if capture_content and isinstance(query := arguments.get("query"), str): + invocation.query_text = query + return invocation + + +def _finish_retrieval_invocation( + invocation: RetrievalInvocation, + response: Mapping[str, Any], + capture_content: bool, +) -> None: + documents = response.get("documents") + if capture_content and isinstance(documents, list): + invocation.documents = documents_to_retrieval_documents(documents) + invocation.stop() + + +def _start_agent_invocation( + handler: TelemetryHandler, + component: Any, + bound_arguments: BoundArguments, + capture_content: bool, +) -> tuple[AgentInvocation, int]: + """Start an AgentInvocation for a Haystack ``Agent.run``. + + The Agent's own LLM calls are already captured as nested ``chat`` + spans -- the Agent instance's ``chat_generator`` is itself a + ``GENERATOR``-classified component, wrapped independently at the class + level regardless of how it's invoked. This invocation only needs to + provide the outer ``invoke_agent`` span that groups them. + + Returns ``(invocation, input_message_count)`` -- the count is needed at + finish time to slice the agent's echoed-back conversation history into + just the newly generated output messages. + """ + arguments = bound_arguments.arguments + agent_name = ( + getattr(component, "name", None) or component.__class__.__name__ + ) + invocation = handler.invoke_local_agent(agent_name=agent_name) + messages = arguments.get("messages") + input_count = len(messages) if isinstance(messages, list) else 0 + if capture_content and isinstance(messages, list): + invocation.input_messages = to_input_messages(messages) + tools = arguments.get("tools") + if capture_content and tools: + invocation.tool_definitions = tools_to_definitions(tools) + return invocation, input_count + + +def _finish_agent_invocation( + invocation: AgentInvocation, + input_count: int, + response: Mapping[str, Any], + capture_content: bool, +) -> None: + if capture_content and isinstance( + all_messages := response.get("messages"), list + ): + new_messages = all_messages[input_count:] or all_messages + invocation.output_messages = chat_replies_to_output_messages( + new_messages + ) + invocation.stop() + + +def _start_component_invocation( + handler: TelemetryHandler, + component_type: ComponentType, + instance: Any, + bound_arguments: BoundArguments, + capture_content: bool, +) -> tuple[Any, Any]: + """Dispatch to the right ``handler.*()`` factory for ``component_type``. + + Returns ``(invocation, extra)`` -- ``extra`` carries whatever bit of + start-time state the matching ``_finish_*`` function needs (``is_chat`` + for generators, the input message count for agents, ``None`` otherwise). + Shared by both the sync and async component wrappers. + """ + if component_type is ComponentType.GENERATOR: + return _start_generator_invocation( + handler, instance, bound_arguments, capture_content + ) + if component_type is ComponentType.EMBEDDER: + return ( + _start_embedding_invocation(handler, instance, capture_content), + None, + ) + if component_type is ComponentType.AGENT: + return _start_agent_invocation( + handler, instance, bound_arguments, capture_content + ) + # RANKER and RETRIEVER share the same retrieval invocation shape. + return ( + _start_retrieval_invocation( + handler, instance, bound_arguments, capture_content + ), + None, + ) + + +def _finish_component_invocation( + component_type: ComponentType, + invocation: Any, + extra: Any, + response: Mapping[str, Any], + capture_content: bool, +) -> None: + if component_type is ComponentType.GENERATOR: + _finish_generator_invocation( + invocation, extra, response, capture_content + ) + elif component_type is ComponentType.EMBEDDER: + _finish_embedding_invocation(invocation, response) + elif component_type is ComponentType.AGENT: + _finish_agent_invocation(invocation, extra, response, capture_content) + else: + _finish_retrieval_invocation(invocation, response, capture_content) + + +def component_run( + handler: TelemetryHandler, component_type: ComponentType +) -> Callable[..., Any]: + """Build a sync wrapper for a component ``run`` method classified as ``component_type``.""" + capture_content = handler.should_capture_content() + + def traced_method( + wrapped: Callable[..., Any], + instance: Any, + args: tuple[Any, ...], + kwargs: Mapping[str, Any], + ) -> Any: + bound_arguments = _bind_arguments(wrapped, args, kwargs) + invocation, extra = _start_component_invocation( + handler, component_type, instance, bound_arguments, capture_content + ) + try: + response = wrapped(*args, **kwargs) + except Exception as exc: + invocation.fail(exc) + raise + _finish_component_invocation( + component_type, invocation, extra, response, capture_content + ) + return response + + return traced_method + + +def component_run_async( + handler: TelemetryHandler, component_type: ComponentType +) -> Callable[..., Any]: + """Build an async wrapper for a component ``run_async`` method classified as ``component_type``.""" + capture_content = handler.should_capture_content() + + async def traced_method( + wrapped: Callable[..., Any], + instance: Any, + args: tuple[Any, ...], + kwargs: Mapping[str, Any], + ) -> Any: + bound_arguments = _bind_arguments(wrapped, args, kwargs) + invocation, extra = _start_component_invocation( + handler, component_type, instance, bound_arguments, capture_content + ) + try: + response = await wrapped(*args, **kwargs) + except Exception as exc: + invocation.fail(exc) + raise + _finish_component_invocation( + component_type, invocation, extra, response, capture_content + ) + return response + + return traced_method + + +# --------------------------------------------------------------------------- +# Tool.invoke / Tool.invoke_async -> ToolInvocation +# --------------------------------------------------------------------------- +# +# haystack.tools.tool.Tool is the single concrete class every Haystack tool +# is built from -- wrapped directly on the class, not via the component +# registry (a Tool is not a Haystack ``Component``). Correlating the span +# with the model's tool_call.id would require hooking the private +# haystack.components.agents.tool_calling._make_context_bound_invoke, which +# is where the id is available; deliberately not done here to avoid +# depending on Haystack internals. + + +def _start_tool_invocation( + handler: TelemetryHandler, instance: Any, kwargs: Mapping[str, Any] +) -> ToolInvocation: + invocation = handler.tool( + name=getattr(instance, "name", None) or instance.__class__.__name__, + tool_type="function", + tool_description=getattr(instance, "description", None), + ) + if invocation.should_capture_content_on_span: + invocation.arguments = dict(kwargs) + return invocation + + +def _finish_tool_invocation(invocation: ToolInvocation, result: Any) -> None: + if invocation.should_capture_content_on_span: + invocation.tool_result = result + invocation.stop() + + +def tool_invoke(handler: TelemetryHandler) -> Callable[..., Any]: + def traced_method( + wrapped: Callable[..., Any], + instance: Any, + args: tuple[Any, ...], + kwargs: Mapping[str, Any], + ) -> Any: + invocation = _start_tool_invocation(handler, instance, kwargs) + try: + result = wrapped(*args, **kwargs) + except Exception as exc: + invocation.fail(exc) + raise + _finish_tool_invocation(invocation, result) + return result + + return traced_method + + +def tool_invoke_async(handler: TelemetryHandler) -> Callable[..., Any]: + async def traced_method( + wrapped: Callable[..., Any], + instance: Any, + args: tuple[Any, ...], + kwargs: Mapping[str, Any], + ) -> Any: + invocation = _start_tool_invocation(handler, instance, kwargs) + try: + result = await wrapped(*args, **kwargs) + except Exception as exc: + invocation.fail(exc) + raise + _finish_tool_invocation(invocation, result) + return result + + return traced_method diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/provider.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/provider.py new file mode 100644 index 000000000..9bf9d30cd --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/provider.py @@ -0,0 +1,64 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Best-effort ``gen_ai.provider.name`` inference from a component's class name. + +Haystack generator/embedder components don't expose a normalized provider +identifier (unlike, say, the OpenAI or Anthropic SDKs, which this repo's +other packages instrument directly). This is a class-name heuristic, +narrowed to provider values that exist in the ``gen_ai.provider.name`` +semconv registry today. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAI, +) + +_OPENAI = GenAI.GenAiProviderNameValues.OPENAI.value +_AZURE_OPENAI = GenAI.GenAiProviderNameValues.AZURE_AI_OPENAI.value +_COHERE = GenAI.GenAiProviderNameValues.COHERE.value +_AWS_BEDROCK = GenAI.GenAiProviderNameValues.AWS_BEDROCK.value +_GCP_VERTEX_AI = GenAI.GenAiProviderNameValues.GCP_VERTEX_AI.value + +_CLASS_NAME_TO_PROVIDER = { + # haystack-ai core + "OpenAIGenerator": _OPENAI, + "OpenAIChatGenerator": _OPENAI, + "OpenAITextEmbedder": _OPENAI, + "OpenAIDocumentEmbedder": _OPENAI, + "AzureOpenAIGenerator": _AZURE_OPENAI, + "AzureOpenAIChatGenerator": _AZURE_OPENAI, + "AzureOpenAITextEmbedder": _AZURE_OPENAI, + "AzureOpenAIDocumentEmbedder": _AZURE_OPENAI, + # cohere-haystack (haystack_integrations.components.{generators,embedders}.cohere) + "CohereGenerator": _COHERE, + "CohereChatGenerator": _COHERE, + "CohereTextEmbedder": _COHERE, + "CohereDocumentEmbedder": _COHERE, + "CohereDocumentImageEmbedder": _COHERE, + # amazon-bedrock-haystack (haystack_integrations.components.{generators,embedders}.amazon_bedrock) + "AmazonBedrockGenerator": _AWS_BEDROCK, + "AmazonBedrockChatGenerator": _AWS_BEDROCK, + "AmazonBedrockTextEmbedder": _AWS_BEDROCK, + "AmazonBedrockDocumentEmbedder": _AWS_BEDROCK, + "AmazonBedrockDocumentImageEmbedder": _AWS_BEDROCK, + # google-vertex-haystack (haystack_integrations.components.generators.google_vertex) + "VertexAIGeminiGenerator": _GCP_VERTEX_AI, + "VertexAIGeminiChatGenerator": _GCP_VERTEX_AI, + "VertexAITextGenerator": _GCP_VERTEX_AI, + "VertexAICodeGenerator": _GCP_VERTEX_AI, +} + + +def infer_provider(component: Any) -> Optional[str]: + """Return the ``gen_ai.provider.name`` value for a generator/embedder component. + + Returns ``None`` for components with no known mapping (e.g. Hugging Face + API generators, whose model string encodes the provider but has no + corresponding ``gen_ai.provider.name`` enum value yet). + """ + return _CLASS_NAME_TO_PROVIDER.get(component.__class__.__name__) diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/version.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/version.py new file mode 100644 index 000000000..8920c2929 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/src/opentelemetry/instrumentation/genai/haystack/version.py @@ -0,0 +1,4 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +__version__ = "1.1b0.dev" diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/__init__.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/embedding_conformance.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/embedding_conformance.yaml new file mode 100644 index 000000000..199dacdce --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/embedding_conformance.yaml @@ -0,0 +1,20 @@ +interactions: +- request: + body: '{"input":["Argentina won the World Cup in 2022.","France won the World + Cup in 2018."],"model":"text-embedding-3-small","encoding_format":"base64"}' + headers: {} + method: POST + uri: https://api.openai.com/v1/embeddings + response: + body: + string: "{\n \"object\": \"list\",\n \"data\": [\n {\n \"object\": + \"embedding\",\n \"index\": 0,\n \"embedding\": \"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\"\n + \ },\n {\n \"object\": \"embedding\",\n \"index\": 1,\n \"embedding\": + \"74UuvS3Smbvura42/CUqvFaKjLuMBc+6QxXnPD2qlD2bBUq9DeX4vMkFuzxt4oS81s02vTU+lzwZdqC7QmVnPcMNPb0V3qE8A/anPIVd0bzUbTc9XqYJvLw9vzxTeg29CBYmPUOOkjxCZWe997WrPJsFSr3+vH09MQXtPHGOg7xq5oW8cXqDvH6NUz3+Xam8BH6nu/P1rDx0dgI9zZ05PBFGo7xZmos8/zUpvU8FY7wq6pq8O14VPSxyGr0zZey8PCIVOz2+lDxksgc9S21kPMSVvLxwygM9IH3yvIGd0jyMBU89j+1NPB+CHjyyrcK8N56WPCWinDxG2hE9n8VIvSrqGr1r9Vm8bH3ZvFC14jw4rWo9X93dPGVihzz0pSw8px3GPJpVyrsonps8fd1TPDwiFTy5LUA9mPVKvVQ+Db0PRXi9FWX2PDwilbw0jpc834Uzu1WyDL35nH87YBoJPBd19bxEnWY8D74jPG9CBD0ARX07AL6oPAFuqDw3shY8XQqKPGylWbwuvhm98ZWtuy36GT1NzWO83MW0ubM1QjwfHfM8+RUrOwfFerwjah29cAYEvDJqmLxk2ge9BS6nuk5947z7/P47j+3NuxjGILw56pW8JiocPEkN5TzP1Ti9gZ3SPKcdRjwchh+9VnYMvAUGJzzBrT09aTaGPC9umbwzZWy8kXVNvXR2Aj1H1eU8LDYavYJ10jxnrgY9420yvW26BLtTjo08jbXOvBhN9TzgXTO83iW0vNjdtTyAFdM8fAqAvFQWDbwfqh69EUYjPUmakDxUPo28BbX7vDxFabzcxbQ6U2aNvBJV9zvA1b281R03u5DFzTyzDUI8QbVnPG5qBL239UA9g/3RvGJmCD0A5ig9dYVWu/ANLrxpDgY8bB4FOwUup7zS5bc8652vuXqqAD1OMo88ZhKHPU8tYz3lpbE8KhKbPGmVWjwYxqA7PaqUvM2duTo3npY7sq1CPVQqjTtEZpI8cGXYPIMlUrsFLqe8PwqUPPjE/7sSziK9BFYnPTkmlryeFUm9B8X6vEbaET2h/Uc9yFU7PGSyB7zDDb288b2tO7Tlwb3P/Ti8mPXKPLuNvzoeRXO8OtYVuy4dbjw75em8cPIDPXwKALxxjgM90K24vHlt1Twb6p+8g/1RuzxF6bqVDcw8VCoNvUPeEj0YTfU71R03PXltVbznLbE6Y3XcvCoSGzwMrqS7x6U7PNINOLxrlgW9cLYDOlxairyozcW8mn3KvCjGG73yRS28MwaYPOZ9Mb1jddy6LeYZvUV1ZjtDohI9Uj3iunt91LyQnU298A0uPYMl0jwccp+8V/6LvCr+mj0DLfy8akVavGKOCLxC8hI8XG4KPXwt1LzcxbS8wP09vT3SFDwfHfO8QVaTu2J6CL0qEpu8L1qZPAqepbvpZbC8CU36PM2duTvcnbS7qKXFPFzNXrwytWw93iW0u2lt2rtf3V08GXaguUR6EjxlYge8YLXdvFnl3zxT7eG8xJW8ugbeJr3frTM8An38vPTNrDwvze08KuoaPXn6gDwXPiE8I1adPBU99rwLTiU9P33ovB5F87y6BUA8UJIOPe0lrzo+zei6dToCPWYSB71E7WY8rGVEPacdxrxx7dc7OtaVPEF+kzxRLo49V63gOotVT71abd86VbKMvHbWAb3cnTS9FbYhPWvNWTxJDWU7rGVEPPqdqrvcnbQ8QVYTPWMqCL0IFqa7B8X6PCB98jtTjg28dHYCPfJFrbvV9ba7tL3BvO39Lj1cggo9OE6WO48VTrx1OgK97q0uPT6ClDwxphg9V4VgOwnupTx21gE9J01wvEetZbzk9TG9wl29PFaKDDxDPWc8is1PvblVQL0gWh69Y53cvEV1Zjzx5a07h5XQvDGSGL10ngI9K+VuPWWeB70KnqW7r3VDvRd19by1lcG8DP6kPGM+iL3eJbQ8Kw1vvFr6ijz0pSw9KtaavLilwLzKjbo81G23vAeOJr1uBVm82N21uyvCGjzXLbY7TKoPPGatW73EbTw8AOaoPE4yDz1LlWS9edKAu3SeAj0t0pm83iU0vZRdzDxsHoW78DUuvZ49STsy3Ww8ZjqHvIqlzzt7pVQ839Wzu3gigbwjQh29/TT+vJFNTbqeFUm8C04lva7FQ7zurS48RMXmPBCWoztkJVy8auYFvQJGKL0UVqI6BS6nvFIVYrwIPia9cwKDvXEV2Dwt0pk7chaDvD8elLwodhs9QbXnPGuqBT3OTTk8nj3JPEPeEj2RTc08bS3Zu6l9xbwdXh89sCXDPCyVbr1yKoO905W3uSnVb71sCoU8WOqLvA++ozl6HdW8AOaoPJdtS73Evbw8zBU6O1/dXbytFUS8dIoCvWlKBr24fUC9auYFO3GiAz1MReS8KHabOhwN9Lxixdy88x0tvSiKG73eJbQ85c0xPDUqFz0b6h89mB3LuxSNdj2jXUc8MAoZPNmNtbpnNVs8pb3GPCmtbzxB3Wc8UQYOvBS19rwb/h+9ZjqHPSHinbtb0go95wUxPe+FLrxl1Vu8/oUpPXlt1bw5Xeq8zBW6vE31Yz1Mqg88FFaivdFdOL155oC8wl29O+ctsTz7TSq9/Fz+vE3N4zsHxfo7NnXrPONFMruo9UU8DQ35vLoFwDt+jdM6CBamu3SeAr1pvVq9VbKMPDG6GD0z3pc8QvISPZa9SzzSDTi9EX33NV9WCb3eJbS8Tn1jPb3tvrvURbc8uVVAvFBCDjwJxqW8dRICOw0NeTx0soK6Y1IIuvMdLTxffgk9ppVGvKZtxjtgZd08b1YEvWAGiTxgtV07Zk6HPN79M706mpU89X0sO3wt1Lx1rda5/uT9u0xFZLxFTWa67SUvPVQWjTv8/Sk9iG3QPGhehjwyQhi8Jp3wuxZmIb3Brb08N54WvG8uBLyeFUm8epYAvV5V3rt35dU7OtaVvEAt6Lwy3ew6UqKNPD4ylLtdCoo7sq3CO17iiTybBUo9I7XxPLYdwbl8VdQ8a81ZPE1GjzwmZpw7OSaWvHbqgTzq7a+7F8X1PF7OiTzx5a08jj3OvAC+KDvMFTq8csXXPDJqGD3JBTs9X2qJPF66CTz33au7Dx34vIn1TzokZfE84r2yPOvFrzyZpcq8QC1oPJOtTL1eVV49KYVvPFGNYjxHipG6DF35PEAtaDwhCp48YwIIvGrmhbw75ek8+9T+PPTNrDrlpTG8J3VwvcPlPDwgRh49SQ3lPKoFRb0RHiM8pm1GvDwOFTxLlWS8V/6LvJL9TLwPvqM8N4oWPN79M72pVcU8hTXRPFcSDD1+jdM7ZzXbu1r6CrzwDa47ziW5PLf1wDxcloo8Qo3nPB+CnjtAzhO8qKVFPGaFW71EPpK8WV6LvF72iTs6rpW8kiVNOzYWFz0ifh28lpVLvBqt9Dzx5a08JsXwO0R6kjkJTXo9LdIZPKW9xjtEPpK8Q6KSPBBuIz1aNou6+XR/PCw2mrwwChm8gcVSO6W9Rrwz3pc8AfX8PE1aj71dpV48O5XpvFDdYrxY6gs9jmXOu5IlTTstDho8R3YRuwC+qDzWzTY8YqIIPFyWCr21bUG9XG4KvQidej0rrho9F8X1vAz+pDxWYow8+I0ru3Y11rt1EgK9+GUrPb8lvrxJrhC9Dr14PT7N6Dv1Vay8RD4SPSfuGz07ShW9bH1ZvWVih7xKveS7maXKPGeuBj3jbTK8M2XsO+cFMT3IVbu8dHYCPVUl4TwYxiC9VJ1hvUkN5bsvMpm8P/aTvBcWobz7dao6PZYUPHEV2LvYBTa9H/XyvCJ+Hb1KSpA8BrYmPCo1b7unRca8sCVDPfwlKjtffgm9R4oRu7KFwrvQrTg8Djakuypd7zuyrcI89VWsPHeaATxdCoo8XM1evFb94DqGDVG9P1XoO2x9WbpIhWW8eUVVvLrdv7xRLo49O14VvbHVwjvG9Ts7OtaVO/6FqTyjXce8F511vFHyDT1sWoU8FT32PDo16rvdTbS8I7VxvDGSGDqyhUI7GCV1u421zrxzAgO8wa09PGEVXTx0ioK87E0vvZblSzwaJiA9/Fx+PAqeJb2Njc47Z8KGPM/9OLwVBiK9GE11vOKVMrwN5fi8Ut6NOnbWAb3Brb08soVCO60VRLzlpbG8rsXDO1xuCr0PvqM6CnYlPVCSDr1Ngo88Q8qSuxaOobvybS29cXqDPHul1LzcxbQ6dIqCvB7mnrzWzba8TKoPvWrShTxi7Vy8Gk4gPU8t4zz33Su8bgVZPHU6grwkPfE8xG08vReddb0gMp68zBW6vDqGFTz8hP67GCX1PAr9+TtH1WU3KTqbvGaF27olthw9ab3aOymt7zvz9Sw8Px6UPE+6jrzKtbo8zO25u1aKDDv4jSs7cY4DPFWyDL0SVXc8CD6mPC9aGT347H+7YC6JPF0Kirskypy8FmYhO2x9WbzzHS28E912O4ht0Lo85pS8e31UvHnSADwObfi8u7W/vDh2ljyfnUi7Xs6JvPvUfrufncg6bc6EvJDFTbz7/H48Gf10uiliG7zJ3bq88kWtvKjNxTz5dP+8Y51cPYVd0TtIhWU9GzX0PETFZjzbFbW8j+3NO609xDydZcm8jxVOPD/2E71CjWe9YGVdvIht0DtvGgQ8FbahPEyWj7wuvpk8CBYmvd+Fs7wt+hk8j+3Nu9/VMzxukgS8ZO6HvP00fjsiuh27N4qWPKW9Rjxlngc8LJVuPP6FqbhKchC8aJqGPEkNZTxvLgS911W2u3bCATwLJqW3X5IJvW8uBD0P5qO7W6oKPectsbu7tT+9BQanvMndOrxKhhA9YcqIPEzSj7xgLom8Lr6ZvIMlUjz6xSq97SUvPCIFcryopcU7th3BPNydNLwVtqE98b0tPPvU/jojLp08NMoXvF5V3jwLTqW8ER4jPVuqijzXVTa8LCIave9dLr3OJbk5x6U7PHMl17xSFWK8NyXrPNX1tjwxkhi8e31UvL9NvrwFZfu7F531PFc6DDwf9XI7DNakvEF+kzsQ9fe8cyVXPL8lvju0vUE8LUXuuyuamrwyjew7cD3YPEbaEboK1Xk86N2wOwyuJLcDzqc8L6VtvHGOg7seRXM9z/24u2dd27xKhpA7BS6nPLJdwrtPLeM8T+IOu0makLsvze07MQXtO1uqirx7fdQ7T7qOOgTd+7xEnWa9W9IKPSHOHbw0ohe8LEoavDNl7Dxlnge7eb4APD0dabzMFTo8d66BvMS9vLw6mpW4J3VwvAlN+jxFFhI9cN4DutCFuDs1Kpe8lF1MvPG9LT3P1bi8HZVzvQTd+zusZcS8ScKQPGdd2zy/Tb68pDVHvKxlRLxt9oS7bAoFPe9drrxx7Vc8L0aZu39l0zxmToc8R9VlO+rtL706hhU7YmYIPc11OTwrmpq8ce1XusndujsD9ic8wP29vLrdPzy8Pb+6R2KRPAnGJT2rtUS9NyXrvFZ2jDwNXiS7FFaivDB9bTwA5qi7eb6AvDAembtbqoq8BQYnvMgtOz1n6ga9TqXjvF9+CTxXheC7HOVzPP2tKT3YBbY8hNXROiWOHL0aTqA7uH3APDTta7tgGgm7+00qPMbNu7wXPqG8OeqVvBCWozs61hU92AU2vKlVRTxB3Wc8XX3evF5VXr2bBco7R04RuXZdVrtn6oa8Sl6QvFb9YLw7NhW7CSX6u8PlPL0jGh29HSKfvAMt/DxwPVi7cGXYOxFGI7wrmhq8Yz6IvD3SFLzsTa+8MOKYPCKSHT04OhY9mPXKPG3iBL0QlqM9D0X4PPz9Kb1hPd072Y21PHpugLw3Jes8/w2pvBYV9jy4fUA64DWzu9RttzyN3U67PaqUPNzFtLwkPXE6IVXyPBJV9ztLIpA8cnXXPGQl3Lv5FSu9Zk6HPD2+FDwodhu9bFqFvKW9Rr0Cffw8x327vE4yj7wWFfa5r53DPGyl2bwn2ps6ZhKHvG32hLsDzie7ve2+PCXtcDzD5bw7i1VPvLWVQTy0vUG8n53IPFiuizxomga9O14VPPV9LLxdMgq72bW1PNRFNzywTcO8/oUpObtlP7x5bVW8ppXGukc6kTyx/cI8hIVRPT2+FD0ccp+7HSIfPXT91jxQfg69rRVEPO+FLj0r5e68OV1qPYrNz7tabV+7by6EOtRtN72OPc47mc1KPWm9WjvD5bw8+Oz/PF0eCj0zPWy7ZZ6HPPtNqjtqRdq7otXHu8PlvLykDcc8Dg4kvUc6kbx2woE6HUofPSK6HT1Zhgu7yd06O0G15zwNhqQ8S/oPvZOtTLwCfXy7wa29vHSKgrwSziI95wUxPbH9Qjyl5cY8Uj3iu3SeAj0GtqY6RrKRvDS2Fz14SgG9KtaaPG3ihDr1fSy98m0tvWx92TxuBdm8xL28u3g2AT1WYgy6YGXdO8b1O7xgtV09RzoRPN+ts7tHThE9epaAvEuV5DzBhb28NT4XuxwNdLxRLg6974WuvNOVN7xxeoO7PR3pvPP1rLxjnVy7VmKMPIwtT7x3hoE8JMocPYSt0byN3c671s02vJDFzbzKtTo9710uvV2lXjw22ha7O16VvM/9uDxNbo+9J01wvBFGI7xVJeE8V4XguzEFbTxcbgo9LqqZvJ/tSL056hU6wNU9PfP1rLwEVqe8chYDvE1uDz1w8oO8YxYIvBed9bxJNWU9YsVcPGKOiDvTvTe8FbahvKdFRr0ljhw9DP4kvPYtLLxqHVo8SIVlvMMNPbxALei8vnU+vectsbs/Veg7o4VHvXseAD2P7c08EUajvDMGmDw99Wg9XJYKPQqepTx5+oC8+Zz/PGTuB7tczV48e81UPLM1QrxZhgu9BFanvFDdYjz0pSw8IB6eO1cSjLsh9h08Zf1bPLS9QT3vXa68HkXzPHiV1TteLV680V24PHY1Vrw1nes8DDV5vEmuELxSog07CD6mPIrNzzwBbig8gyXSO172iTqePUm6SF3lvO39LrwahfS8NWYXPSQGnTwG3iY9O0qVPCnVbzw3JWu8vcW+uEgmkTtPLeO7Fz4hPSglcL30pay7Ay38PPhlq7sUjXa8ElX3O449zjxpIga8vp2+vO7Vrrtldge9EPV3PEG1Z7zpZTC9VU3huWBl3bsGPXu8VyaMvCKSnbxS3o28X34JvZpVyrxSto28Zk4HvWlKBj13hgG9tW3BPKotxTwVBiI8sdXCO0PKEj0eDp87dsKBvH610zw+bhS9NKIXPWM+iDzKtTq8px3GPLM1wruo9cW6+iT/vAUuJz1plVq8Kw3vuk8FY7zhDTM8zZ25vHUmAr3q7a+8F3V1vIwFT7ynHUa8eeYAPE/ijrxUKg28X2oJvHqCADwK/Xk6WA3gPI5lTrwA5ig9A84nvXA9WDyIbVC8M2XsvGiahjwslW48ULViPFCSDrwoJfA8EPV3vcndujx8LVS9MaaYPNFdOLoY7iA8Ay18PBgldTzGzTs8WZqLvP81Kb2Xbcs89i0sPDTKl7wIPia9EJYjPVyWiry0vcG8YGXdOxjuoDwS9qI8qKXFvGMqCLsulpm8o13HvF/d3bxG2pG82Y21PHNNV7y6BcC3O3IVPQ4OJL1c9V67dw1WPQMtfD0w4hg8PA4VPQIeqDpfagm9zZ05vXbCgbxDFee8aA1bPHLFV7xkTdw7rT3EPMkFu7s8DhW87f0uPXlF1bwQlqO8jmVOvT8eFD1BVhM6yz06PA42pDw2deu8aW1au4tVz7lW1eA6kv1MPF72CbwFtXs8t/XAOwOmJ7w9qpQ8+3WqPIwFTzoAHX08CyalOiAeHro+bhS9422yPATde7xzAgO9pb3GvAyupDw+pWi8sCVDPC9uGTuUXUw8JmYcvSO1cbzJBTu8UQYOvT2+FD0sSpq6/zUpvE2CjzwCHig87HUvvV66Cb3gNbO8XFoKvUAtaDvSDbg8gyVSPGC13TmzNcI8Tn3jO8s9ujxALeg7SIVlPB5F87yOZU68SP4QPHeuAT3ILTs6QbVnPfe1Kz36nSq9P1VoPFPF4TxDthK9AZYovRW2oTutFUS9LpYZPcMNvTwaJqA8Fz4hO9dVNrxLbWS8eqqAvIU10bqjhce7650vvPjs/zsmUpw8YnqIvC+lbTvFHbw85wUxPQz+pDv7/H48YAYJvVb94Lui1cc7xvW7uz9V6Lx1hdY7nhVJvHcNVrxPLeM80IU4vXKd1zxO9g49\"\n + \ }\n ],\n \"model\": \"text-embedding-3-small\",\n \"usage\": {\n \"prompt_tokens\": + 20,\n \"total_tokens\": 20\n }\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/inference_conformance.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/inference_conformance.yaml new file mode 100644 index 000000000..7d5c36fc4 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/inference_conformance.yaml @@ -0,0 +1,23 @@ +interactions: +- request: + body: '{"messages": [{"role": "system", "content": "Answer user questions succinctly"}, + {"role": "assistant", "content": "What can I help you with?"}, {"role": "user", + "content": "Who won the World Cup in 2022? Answer in one word."}], "model": + "gpt-4o", "stream": false}' + headers: {} + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"id\": \"chatcmpl-9uAIpyzbpEOCAc8t1Perus79hLl8l\",\n \"object\": + \"chat.completion\",\n \"created\": 1723172999,\n \"model\": \"gpt-4o-2024-05-13\",\n + \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": + \"assistant\",\n \"content\": \"Argentina.\",\n \"refusal\": + null\n },\n \"logprobs\": null,\n \"finish_reason\": \"stop\"\n + \ }\n ],\n \"usage\": {\n \"prompt_tokens\": 42,\n \"completion_tokens\": + 2,\n \"total_tokens\": 44\n },\n \"system_fingerprint\": \"fp_3aa7262c27\"\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_async.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_async.yaml new file mode 100644 index 000000000..0f46ec769 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_async.yaml @@ -0,0 +1,26 @@ +interactions: +- request: + body: '{"messages":[{"role":"system","content":"Answer user questions succinctly"},{"role":"assistant","content":"What + can I help you with?"},{"role":"user","content":"Who won the World Cup in 2022? + Answer in one word."}],"model":"gpt-4o","n":1,"response_format":null,"stream":false}' + headers: {} + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"id\": \"chatcmpl-CQQR1nX4weuzW8AGhdjy4rGCBK8Wg\",\n \"object\": + \"chat.completion\",\n \"created\": 1760414179,\n \"model\": \"gpt-4o-2024-08-06\",\n + \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": + \"assistant\",\n \"content\": \"Argentina.\",\n \"refusal\": + null,\n \"annotations\": []\n },\n \"logprobs\": null,\n + \ \"finish_reason\": \"stop\"\n }\n ],\n \"usage\": {\n \"prompt_tokens\": + 42,\n \"completion_tokens\": 2,\n \"total_tokens\": 44,\n \"prompt_tokens_details\": + {\n \"cached_tokens\": 0,\n \"audio_tokens\": 0\n },\n \"completion_tokens_details\": + {\n \"reasoning_tokens\": 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": + 0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\": + \"default\",\n \"system_fingerprint\": \"fp_f64f290af2\"\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_error.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_error.yaml new file mode 100644 index 000000000..b6cd71d67 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_error.yaml @@ -0,0 +1,18 @@ +interactions: +- request: + body: '{"messages": [{"role": "user", "content": "Who won the World Cup in 2022? + Answer in one word."}], "model": "gpt-4o", "stream": false}' + headers: {} + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"error\": {\n \"message\": \"Incorrect API key provided: + sk-. You can find your API key at https://platform.openai.com/account/api-keys.\",\n + \ \"type\": \"invalid_request_error\",\n \"param\": null,\n \"code\": + \"invalid_api_key\"\n }\n}\n" + headers: {} + status: + code: 401 + message: Unauthorized +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_no_content_capture.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_no_content_capture.yaml new file mode 100644 index 000000000..7d5c36fc4 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_no_content_capture.yaml @@ -0,0 +1,23 @@ +interactions: +- request: + body: '{"messages": [{"role": "system", "content": "Answer user questions succinctly"}, + {"role": "assistant", "content": "What can I help you with?"}, {"role": "user", + "content": "Who won the World Cup in 2022? Answer in one word."}], "model": + "gpt-4o", "stream": false}' + headers: {} + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"id\": \"chatcmpl-9uAIpyzbpEOCAc8t1Perus79hLl8l\",\n \"object\": + \"chat.completion\",\n \"created\": 1723172999,\n \"model\": \"gpt-4o-2024-05-13\",\n + \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": + \"assistant\",\n \"content\": \"Argentina.\",\n \"refusal\": + null\n },\n \"logprobs\": null,\n \"finish_reason\": \"stop\"\n + \ }\n ],\n \"usage\": {\n \"prompt_tokens\": 42,\n \"completion_tokens\": + 2,\n \"total_tokens\": 44\n },\n \"system_fingerprint\": \"fp_3aa7262c27\"\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_sync.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_sync.yaml new file mode 100644 index 000000000..7d5c36fc4 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_chat_generator_sync.yaml @@ -0,0 +1,23 @@ +interactions: +- request: + body: '{"messages": [{"role": "system", "content": "Answer user questions succinctly"}, + {"role": "assistant", "content": "What can I help you with?"}, {"role": "user", + "content": "Who won the World Cup in 2022? Answer in one word."}], "model": + "gpt-4o", "stream": false}' + headers: {} + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"id\": \"chatcmpl-9uAIpyzbpEOCAc8t1Perus79hLl8l\",\n \"object\": + \"chat.completion\",\n \"created\": 1723172999,\n \"model\": \"gpt-4o-2024-05-13\",\n + \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": + \"assistant\",\n \"content\": \"Argentina.\",\n \"refusal\": + null\n },\n \"logprobs\": null,\n \"finish_reason\": \"stop\"\n + \ }\n ],\n \"usage\": {\n \"prompt_tokens\": 42,\n \"completion_tokens\": + 2,\n \"total_tokens\": 44\n },\n \"system_fingerprint\": \"fp_3aa7262c27\"\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_document_embedder.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_document_embedder.yaml new file mode 100644 index 000000000..199dacdce --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_document_embedder.yaml @@ -0,0 +1,20 @@ +interactions: +- request: + body: '{"input":["Argentina won the World Cup in 2022.","France won the World + Cup in 2018."],"model":"text-embedding-3-small","encoding_format":"base64"}' + headers: {} + method: POST + uri: https://api.openai.com/v1/embeddings + response: + body: + string: "{\n \"object\": \"list\",\n \"data\": [\n {\n \"object\": + \"embedding\",\n \"index\": 0,\n \"embedding\": \"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\"\n + \ },\n {\n \"object\": \"embedding\",\n \"index\": 1,\n \"embedding\": + \"74UuvS3Smbvura42/CUqvFaKjLuMBc+6QxXnPD2qlD2bBUq9DeX4vMkFuzxt4oS81s02vTU+lzwZdqC7QmVnPcMNPb0V3qE8A/anPIVd0bzUbTc9XqYJvLw9vzxTeg29CBYmPUOOkjxCZWe997WrPJsFSr3+vH09MQXtPHGOg7xq5oW8cXqDvH6NUz3+Xam8BH6nu/P1rDx0dgI9zZ05PBFGo7xZmos8/zUpvU8FY7wq6pq8O14VPSxyGr0zZey8PCIVOz2+lDxksgc9S21kPMSVvLxwygM9IH3yvIGd0jyMBU89j+1NPB+CHjyyrcK8N56WPCWinDxG2hE9n8VIvSrqGr1r9Vm8bH3ZvFC14jw4rWo9X93dPGVihzz0pSw8px3GPJpVyrsonps8fd1TPDwiFTy5LUA9mPVKvVQ+Db0PRXi9FWX2PDwilbw0jpc834Uzu1WyDL35nH87YBoJPBd19bxEnWY8D74jPG9CBD0ARX07AL6oPAFuqDw3shY8XQqKPGylWbwuvhm98ZWtuy36GT1NzWO83MW0ubM1QjwfHfM8+RUrOwfFerwjah29cAYEvDJqmLxk2ge9BS6nuk5947z7/P47j+3NuxjGILw56pW8JiocPEkN5TzP1Ti9gZ3SPKcdRjwchh+9VnYMvAUGJzzBrT09aTaGPC9umbwzZWy8kXVNvXR2Aj1H1eU8LDYavYJ10jxnrgY9420yvW26BLtTjo08jbXOvBhN9TzgXTO83iW0vNjdtTyAFdM8fAqAvFQWDbwfqh69EUYjPUmakDxUPo28BbX7vDxFabzcxbQ6U2aNvBJV9zvA1b281R03u5DFzTyzDUI8QbVnPG5qBL239UA9g/3RvGJmCD0A5ig9dYVWu/ANLrxpDgY8bB4FOwUup7zS5bc8652vuXqqAD1OMo88ZhKHPU8tYz3lpbE8KhKbPGmVWjwYxqA7PaqUvM2duTo3npY7sq1CPVQqjTtEZpI8cGXYPIMlUrsFLqe8PwqUPPjE/7sSziK9BFYnPTkmlryeFUm9B8X6vEbaET2h/Uc9yFU7PGSyB7zDDb288b2tO7Tlwb3P/Ti8mPXKPLuNvzoeRXO8OtYVuy4dbjw75em8cPIDPXwKALxxjgM90K24vHlt1Twb6p+8g/1RuzxF6bqVDcw8VCoNvUPeEj0YTfU71R03PXltVbznLbE6Y3XcvCoSGzwMrqS7x6U7PNINOLxrlgW9cLYDOlxairyozcW8mn3KvCjGG73yRS28MwaYPOZ9Mb1jddy6LeYZvUV1ZjtDohI9Uj3iunt91LyQnU298A0uPYMl0jwccp+8V/6LvCr+mj0DLfy8akVavGKOCLxC8hI8XG4KPXwt1LzcxbS8wP09vT3SFDwfHfO8QVaTu2J6CL0qEpu8L1qZPAqepbvpZbC8CU36PM2duTvcnbS7qKXFPFzNXrwytWw93iW0u2lt2rtf3V08GXaguUR6EjxlYge8YLXdvFnl3zxT7eG8xJW8ugbeJr3frTM8An38vPTNrDwvze08KuoaPXn6gDwXPiE8I1adPBU99rwLTiU9P33ovB5F87y6BUA8UJIOPe0lrzo+zei6dToCPWYSB71E7WY8rGVEPacdxrxx7dc7OtaVPEF+kzxRLo49V63gOotVT71abd86VbKMvHbWAb3cnTS9FbYhPWvNWTxJDWU7rGVEPPqdqrvcnbQ8QVYTPWMqCL0IFqa7B8X6PCB98jtTjg28dHYCPfJFrbvV9ba7tL3BvO39Lj1cggo9OE6WO48VTrx1OgK97q0uPT6ClDwxphg9V4VgOwnupTx21gE9J01wvEetZbzk9TG9wl29PFaKDDxDPWc8is1PvblVQL0gWh69Y53cvEV1Zjzx5a07h5XQvDGSGL10ngI9K+VuPWWeB70KnqW7r3VDvRd19by1lcG8DP6kPGM+iL3eJbQ8Kw1vvFr6ijz0pSw9KtaavLilwLzKjbo81G23vAeOJr1uBVm82N21uyvCGjzXLbY7TKoPPGatW73EbTw8AOaoPE4yDz1LlWS9edKAu3SeAj0t0pm83iU0vZRdzDxsHoW78DUuvZ49STsy3Ww8ZjqHvIqlzzt7pVQ839Wzu3gigbwjQh29/TT+vJFNTbqeFUm8C04lva7FQ7zurS48RMXmPBCWoztkJVy8auYFvQJGKL0UVqI6BS6nvFIVYrwIPia9cwKDvXEV2Dwt0pk7chaDvD8elLwodhs9QbXnPGuqBT3OTTk8nj3JPEPeEj2RTc08bS3Zu6l9xbwdXh89sCXDPCyVbr1yKoO905W3uSnVb71sCoU8WOqLvA++ozl6HdW8AOaoPJdtS73Evbw8zBU6O1/dXbytFUS8dIoCvWlKBr24fUC9auYFO3GiAz1MReS8KHabOhwN9Lxixdy88x0tvSiKG73eJbQ85c0xPDUqFz0b6h89mB3LuxSNdj2jXUc8MAoZPNmNtbpnNVs8pb3GPCmtbzxB3Wc8UQYOvBS19rwb/h+9ZjqHPSHinbtb0go95wUxPe+FLrxl1Vu8/oUpPXlt1bw5Xeq8zBW6vE31Yz1Mqg88FFaivdFdOL155oC8wl29O+ctsTz7TSq9/Fz+vE3N4zsHxfo7NnXrPONFMruo9UU8DQ35vLoFwDt+jdM6CBamu3SeAr1pvVq9VbKMPDG6GD0z3pc8QvISPZa9SzzSDTi9EX33NV9WCb3eJbS8Tn1jPb3tvrvURbc8uVVAvFBCDjwJxqW8dRICOw0NeTx0soK6Y1IIuvMdLTxffgk9ppVGvKZtxjtgZd08b1YEvWAGiTxgtV07Zk6HPN79M706mpU89X0sO3wt1Lx1rda5/uT9u0xFZLxFTWa67SUvPVQWjTv8/Sk9iG3QPGhehjwyQhi8Jp3wuxZmIb3Brb08N54WvG8uBLyeFUm8epYAvV5V3rt35dU7OtaVvEAt6Lwy3ew6UqKNPD4ylLtdCoo7sq3CO17iiTybBUo9I7XxPLYdwbl8VdQ8a81ZPE1GjzwmZpw7OSaWvHbqgTzq7a+7F8X1PF7OiTzx5a08jj3OvAC+KDvMFTq8csXXPDJqGD3JBTs9X2qJPF66CTz33au7Dx34vIn1TzokZfE84r2yPOvFrzyZpcq8QC1oPJOtTL1eVV49KYVvPFGNYjxHipG6DF35PEAtaDwhCp48YwIIvGrmhbw75ek8+9T+PPTNrDrlpTG8J3VwvcPlPDwgRh49SQ3lPKoFRb0RHiM8pm1GvDwOFTxLlWS8V/6LvJL9TLwPvqM8N4oWPN79M72pVcU8hTXRPFcSDD1+jdM7ZzXbu1r6CrzwDa47ziW5PLf1wDxcloo8Qo3nPB+CnjtAzhO8qKVFPGaFW71EPpK8WV6LvF72iTs6rpW8kiVNOzYWFz0ifh28lpVLvBqt9Dzx5a08JsXwO0R6kjkJTXo9LdIZPKW9xjtEPpK8Q6KSPBBuIz1aNou6+XR/PCw2mrwwChm8gcVSO6W9Rrwz3pc8AfX8PE1aj71dpV48O5XpvFDdYrxY6gs9jmXOu5IlTTstDho8R3YRuwC+qDzWzTY8YqIIPFyWCr21bUG9XG4KvQidej0rrho9F8X1vAz+pDxWYow8+I0ru3Y11rt1EgK9+GUrPb8lvrxJrhC9Dr14PT7N6Dv1Vay8RD4SPSfuGz07ShW9bH1ZvWVih7xKveS7maXKPGeuBj3jbTK8M2XsO+cFMT3IVbu8dHYCPVUl4TwYxiC9VJ1hvUkN5bsvMpm8P/aTvBcWobz7dao6PZYUPHEV2LvYBTa9H/XyvCJ+Hb1KSpA8BrYmPCo1b7unRca8sCVDPfwlKjtffgm9R4oRu7KFwrvQrTg8Djakuypd7zuyrcI89VWsPHeaATxdCoo8XM1evFb94DqGDVG9P1XoO2x9WbpIhWW8eUVVvLrdv7xRLo49O14VvbHVwjvG9Ts7OtaVO/6FqTyjXce8F511vFHyDT1sWoU8FT32PDo16rvdTbS8I7VxvDGSGDqyhUI7GCV1u421zrxzAgO8wa09PGEVXTx0ioK87E0vvZblSzwaJiA9/Fx+PAqeJb2Njc47Z8KGPM/9OLwVBiK9GE11vOKVMrwN5fi8Ut6NOnbWAb3Brb08soVCO60VRLzlpbG8rsXDO1xuCr0PvqM6CnYlPVCSDr1Ngo88Q8qSuxaOobvybS29cXqDPHul1LzcxbQ6dIqCvB7mnrzWzba8TKoPvWrShTxi7Vy8Gk4gPU8t4zz33Su8bgVZPHU6grwkPfE8xG08vReddb0gMp68zBW6vDqGFTz8hP67GCX1PAr9+TtH1WU3KTqbvGaF27olthw9ab3aOymt7zvz9Sw8Px6UPE+6jrzKtbo8zO25u1aKDDv4jSs7cY4DPFWyDL0SVXc8CD6mPC9aGT347H+7YC6JPF0Kirskypy8FmYhO2x9WbzzHS28E912O4ht0Lo85pS8e31UvHnSADwObfi8u7W/vDh2ljyfnUi7Xs6JvPvUfrufncg6bc6EvJDFTbz7/H48Gf10uiliG7zJ3bq88kWtvKjNxTz5dP+8Y51cPYVd0TtIhWU9GzX0PETFZjzbFbW8j+3NO609xDydZcm8jxVOPD/2E71CjWe9YGVdvIht0DtvGgQ8FbahPEyWj7wuvpk8CBYmvd+Fs7wt+hk8j+3Nu9/VMzxukgS8ZO6HvP00fjsiuh27N4qWPKW9Rjxlngc8LJVuPP6FqbhKchC8aJqGPEkNZTxvLgS911W2u3bCATwLJqW3X5IJvW8uBD0P5qO7W6oKPectsbu7tT+9BQanvMndOrxKhhA9YcqIPEzSj7xgLom8Lr6ZvIMlUjz6xSq97SUvPCIFcryopcU7th3BPNydNLwVtqE98b0tPPvU/jojLp08NMoXvF5V3jwLTqW8ER4jPVuqijzXVTa8LCIave9dLr3OJbk5x6U7PHMl17xSFWK8NyXrPNX1tjwxkhi8e31UvL9NvrwFZfu7F531PFc6DDwf9XI7DNakvEF+kzsQ9fe8cyVXPL8lvju0vUE8LUXuuyuamrwyjew7cD3YPEbaEboK1Xk86N2wOwyuJLcDzqc8L6VtvHGOg7seRXM9z/24u2dd27xKhpA7BS6nPLJdwrtPLeM8T+IOu0makLsvze07MQXtO1uqirx7fdQ7T7qOOgTd+7xEnWa9W9IKPSHOHbw0ohe8LEoavDNl7Dxlnge7eb4APD0dabzMFTo8d66BvMS9vLw6mpW4J3VwvAlN+jxFFhI9cN4DutCFuDs1Kpe8lF1MvPG9LT3P1bi8HZVzvQTd+zusZcS8ScKQPGdd2zy/Tb68pDVHvKxlRLxt9oS7bAoFPe9drrxx7Vc8L0aZu39l0zxmToc8R9VlO+rtL706hhU7YmYIPc11OTwrmpq8ce1XusndujsD9ic8wP29vLrdPzy8Pb+6R2KRPAnGJT2rtUS9NyXrvFZ2jDwNXiS7FFaivDB9bTwA5qi7eb6AvDAembtbqoq8BQYnvMgtOz1n6ga9TqXjvF9+CTxXheC7HOVzPP2tKT3YBbY8hNXROiWOHL0aTqA7uH3APDTta7tgGgm7+00qPMbNu7wXPqG8OeqVvBCWozs61hU92AU2vKlVRTxB3Wc8XX3evF5VXr2bBco7R04RuXZdVrtn6oa8Sl6QvFb9YLw7NhW7CSX6u8PlPL0jGh29HSKfvAMt/DxwPVi7cGXYOxFGI7wrmhq8Yz6IvD3SFLzsTa+8MOKYPCKSHT04OhY9mPXKPG3iBL0QlqM9D0X4PPz9Kb1hPd072Y21PHpugLw3Jes8/w2pvBYV9jy4fUA64DWzu9RttzyN3U67PaqUPNzFtLwkPXE6IVXyPBJV9ztLIpA8cnXXPGQl3Lv5FSu9Zk6HPD2+FDwodhu9bFqFvKW9Rr0Cffw8x327vE4yj7wWFfa5r53DPGyl2bwn2ps6ZhKHvG32hLsDzie7ve2+PCXtcDzD5bw7i1VPvLWVQTy0vUG8n53IPFiuizxomga9O14VPPV9LLxdMgq72bW1PNRFNzywTcO8/oUpObtlP7x5bVW8ppXGukc6kTyx/cI8hIVRPT2+FD0ccp+7HSIfPXT91jxQfg69rRVEPO+FLj0r5e68OV1qPYrNz7tabV+7by6EOtRtN72OPc47mc1KPWm9WjvD5bw8+Oz/PF0eCj0zPWy7ZZ6HPPtNqjtqRdq7otXHu8PlvLykDcc8Dg4kvUc6kbx2woE6HUofPSK6HT1Zhgu7yd06O0G15zwNhqQ8S/oPvZOtTLwCfXy7wa29vHSKgrwSziI95wUxPbH9Qjyl5cY8Uj3iu3SeAj0GtqY6RrKRvDS2Fz14SgG9KtaaPG3ihDr1fSy98m0tvWx92TxuBdm8xL28u3g2AT1WYgy6YGXdO8b1O7xgtV09RzoRPN+ts7tHThE9epaAvEuV5DzBhb28NT4XuxwNdLxRLg6974WuvNOVN7xxeoO7PR3pvPP1rLxjnVy7VmKMPIwtT7x3hoE8JMocPYSt0byN3c671s02vJDFzbzKtTo9710uvV2lXjw22ha7O16VvM/9uDxNbo+9J01wvBFGI7xVJeE8V4XguzEFbTxcbgo9LqqZvJ/tSL056hU6wNU9PfP1rLwEVqe8chYDvE1uDz1w8oO8YxYIvBed9bxJNWU9YsVcPGKOiDvTvTe8FbahvKdFRr0ljhw9DP4kvPYtLLxqHVo8SIVlvMMNPbxALei8vnU+vectsbs/Veg7o4VHvXseAD2P7c08EUajvDMGmDw99Wg9XJYKPQqepTx5+oC8+Zz/PGTuB7tczV48e81UPLM1QrxZhgu9BFanvFDdYjz0pSw8IB6eO1cSjLsh9h08Zf1bPLS9QT3vXa68HkXzPHiV1TteLV680V24PHY1Vrw1nes8DDV5vEmuELxSog07CD6mPIrNzzwBbig8gyXSO172iTqePUm6SF3lvO39LrwahfS8NWYXPSQGnTwG3iY9O0qVPCnVbzw3JWu8vcW+uEgmkTtPLeO7Fz4hPSglcL30pay7Ay38PPhlq7sUjXa8ElX3O449zjxpIga8vp2+vO7Vrrtldge9EPV3PEG1Z7zpZTC9VU3huWBl3bsGPXu8VyaMvCKSnbxS3o28X34JvZpVyrxSto28Zk4HvWlKBj13hgG9tW3BPKotxTwVBiI8sdXCO0PKEj0eDp87dsKBvH610zw+bhS9NKIXPWM+iDzKtTq8px3GPLM1wruo9cW6+iT/vAUuJz1plVq8Kw3vuk8FY7zhDTM8zZ25vHUmAr3q7a+8F3V1vIwFT7ynHUa8eeYAPE/ijrxUKg28X2oJvHqCADwK/Xk6WA3gPI5lTrwA5ig9A84nvXA9WDyIbVC8M2XsvGiahjwslW48ULViPFCSDrwoJfA8EPV3vcndujx8LVS9MaaYPNFdOLoY7iA8Ay18PBgldTzGzTs8WZqLvP81Kb2Xbcs89i0sPDTKl7wIPia9EJYjPVyWiry0vcG8YGXdOxjuoDwS9qI8qKXFvGMqCLsulpm8o13HvF/d3bxG2pG82Y21PHNNV7y6BcC3O3IVPQ4OJL1c9V67dw1WPQMtfD0w4hg8PA4VPQIeqDpfagm9zZ05vXbCgbxDFee8aA1bPHLFV7xkTdw7rT3EPMkFu7s8DhW87f0uPXlF1bwQlqO8jmVOvT8eFD1BVhM6yz06PA42pDw2deu8aW1au4tVz7lW1eA6kv1MPF72CbwFtXs8t/XAOwOmJ7w9qpQ8+3WqPIwFTzoAHX08CyalOiAeHro+bhS9422yPATde7xzAgO9pb3GvAyupDw+pWi8sCVDPC9uGTuUXUw8JmYcvSO1cbzJBTu8UQYOvT2+FD0sSpq6/zUpvE2CjzwCHig87HUvvV66Cb3gNbO8XFoKvUAtaDvSDbg8gyVSPGC13TmzNcI8Tn3jO8s9ujxALeg7SIVlPB5F87yOZU68SP4QPHeuAT3ILTs6QbVnPfe1Kz36nSq9P1VoPFPF4TxDthK9AZYovRW2oTutFUS9LpYZPcMNvTwaJqA8Fz4hO9dVNrxLbWS8eqqAvIU10bqjhce7650vvPjs/zsmUpw8YnqIvC+lbTvFHbw85wUxPQz+pDv7/H48YAYJvVb94Lui1cc7xvW7uz9V6Lx1hdY7nhVJvHcNVrxPLeM80IU4vXKd1zxO9g49\"\n + \ }\n ],\n \"model\": \"text-embedding-3-small\",\n \"usage\": {\n \"prompt_tokens\": + 20,\n \"total_tokens\": 20\n }\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_pipeline_run_async_produces_workflow_and_chat_spans.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_pipeline_run_async_produces_workflow_and_chat_spans.yaml new file mode 100644 index 000000000..0f46ec769 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_pipeline_run_async_produces_workflow_and_chat_spans.yaml @@ -0,0 +1,26 @@ +interactions: +- request: + body: '{"messages":[{"role":"system","content":"Answer user questions succinctly"},{"role":"assistant","content":"What + can I help you with?"},{"role":"user","content":"Who won the World Cup in 2022? + Answer in one word."}],"model":"gpt-4o","n":1,"response_format":null,"stream":false}' + headers: {} + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"id\": \"chatcmpl-CQQR1nX4weuzW8AGhdjy4rGCBK8Wg\",\n \"object\": + \"chat.completion\",\n \"created\": 1760414179,\n \"model\": \"gpt-4o-2024-08-06\",\n + \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": + \"assistant\",\n \"content\": \"Argentina.\",\n \"refusal\": + null,\n \"annotations\": []\n },\n \"logprobs\": null,\n + \ \"finish_reason\": \"stop\"\n }\n ],\n \"usage\": {\n \"prompt_tokens\": + 42,\n \"completion_tokens\": 2,\n \"total_tokens\": 44,\n \"prompt_tokens_details\": + {\n \"cached_tokens\": 0,\n \"audio_tokens\": 0\n },\n \"completion_tokens_details\": + {\n \"reasoning_tokens\": 0,\n \"audio_tokens\": 0,\n \"accepted_prediction_tokens\": + 0,\n \"rejected_prediction_tokens\": 0\n }\n },\n \"service_tier\": + \"default\",\n \"system_fingerprint\": \"fp_f64f290af2\"\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_pipeline_run_produces_workflow_and_chat_spans.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_pipeline_run_produces_workflow_and_chat_spans.yaml new file mode 100644 index 000000000..a73113900 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_pipeline_run_produces_workflow_and_chat_spans.yaml @@ -0,0 +1,22 @@ +interactions: +- request: + body: '{"messages": [{"role": "system", "content": "Answer concisely in one sentence."}, + {"role": "user", "content": "What country is Berlin in?"}], "model": "gpt-4o", + "stream": false}' + headers: {} + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"id\": \"chatcmpl-9wcf63ElMv37nCRAW9VlNU9BJKFZV\",\n \"object\": + \"chat.completion\",\n \"created\": 1723758668,\n \"model\": \"gpt-4o-2024-05-13\",\n + \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": + \"assistant\",\n \"content\": \"Germany.\",\n \"refusal\": null\n + \ },\n \"logprobs\": null,\n \"finish_reason\": \"stop\"\n }\n + \ ],\n \"usage\": {\n \"prompt_tokens\": 25,\n \"completion_tokens\": + 2,\n \"total_tokens\": 27\n },\n \"system_fingerprint\": \"fp_3aa7262c27\"\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_tool_calling_captures_tool_call_on_output_message.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_tool_calling_captures_tool_call_on_output_message.yaml new file mode 100644 index 000000000..9ff8b76b5 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/test_tool_calling_captures_tool_call_on_output_message.yaml @@ -0,0 +1,28 @@ +interactions: +- request: + body: '{"messages": [{"role": "user", "content": "What is the weather in Berlin"}], + "model": "gpt-4o", "stream": false, "tools": [{"type": "function", "function": + {"name": "get_current_weather", "description": "Get the current weather", "parameters": + {"type": "object", "properties": {"location": {"type": "string", "description": + "The city and state, e.g. San Francisco, CA"}}, "required": ["location"]}}}]}' + headers: {} + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"id\": \"chatcmpl-9vs6CCN08nbDKpovcEGBQkpJOtjzQ\",\n \"object\": + \"chat.completion\",\n \"created\": 1723579680,\n \"model\": \"gpt-4o-2024-05-13\",\n + \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": + \"assistant\",\n \"content\": null,\n \"tool_calls\": [\n {\n + \ \"id\": \"call_bEtwR8qUAagwWFQr5OcKmmLE\",\n \"type\": + \"function\",\n \"function\": {\n \"name\": \"get_current_weather\",\n + \ \"arguments\": \"{\\\"location\\\":\\\"Berlin\\\"}\"\n }\n + \ }\n ],\n \"refusal\": null\n },\n \"logprobs\": + null,\n \"finish_reason\": \"tool_calls\"\n }\n ],\n \"usage\": + {\n \"prompt_tokens\": 63,\n \"completion_tokens\": 15,\n \"total_tokens\": + 78\n },\n \"system_fingerprint\": \"fp_c9aa9c0491\"\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/tool_calling_conformance.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/tool_calling_conformance.yaml new file mode 100644 index 000000000..9ff8b76b5 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/tool_calling_conformance.yaml @@ -0,0 +1,28 @@ +interactions: +- request: + body: '{"messages": [{"role": "user", "content": "What is the weather in Berlin"}], + "model": "gpt-4o", "stream": false, "tools": [{"type": "function", "function": + {"name": "get_current_weather", "description": "Get the current weather", "parameters": + {"type": "object", "properties": {"location": {"type": "string", "description": + "The city and state, e.g. San Francisco, CA"}}, "required": ["location"]}}}]}' + headers: {} + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"id\": \"chatcmpl-9vs6CCN08nbDKpovcEGBQkpJOtjzQ\",\n \"object\": + \"chat.completion\",\n \"created\": 1723579680,\n \"model\": \"gpt-4o-2024-05-13\",\n + \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": + \"assistant\",\n \"content\": null,\n \"tool_calls\": [\n {\n + \ \"id\": \"call_bEtwR8qUAagwWFQr5OcKmmLE\",\n \"type\": + \"function\",\n \"function\": {\n \"name\": \"get_current_weather\",\n + \ \"arguments\": \"{\\\"location\\\":\\\"Berlin\\\"}\"\n }\n + \ }\n ],\n \"refusal\": null\n },\n \"logprobs\": + null,\n \"finish_reason\": \"tool_calls\"\n }\n ],\n \"usage\": + {\n \"prompt_tokens\": 63,\n \"completion_tokens\": 15,\n \"total_tokens\": + 78\n },\n \"system_fingerprint\": \"fp_c9aa9c0491\"\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/workflow_conformance.yaml b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/workflow_conformance.yaml new file mode 100644 index 000000000..a73113900 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/cassettes/workflow_conformance.yaml @@ -0,0 +1,22 @@ +interactions: +- request: + body: '{"messages": [{"role": "system", "content": "Answer concisely in one sentence."}, + {"role": "user", "content": "What country is Berlin in?"}], "model": "gpt-4o", + "stream": false}' + headers: {} + method: POST + uri: https://api.openai.com/v1/chat/completions + response: + body: + string: "{\n \"id\": \"chatcmpl-9wcf63ElMv37nCRAW9VlNU9BJKFZV\",\n \"object\": + \"chat.completion\",\n \"created\": 1723758668,\n \"model\": \"gpt-4o-2024-05-13\",\n + \ \"choices\": [\n {\n \"index\": 0,\n \"message\": {\n \"role\": + \"assistant\",\n \"content\": \"Germany.\",\n \"refusal\": null\n + \ },\n \"logprobs\": null,\n \"finish_reason\": \"stop\"\n }\n + \ ],\n \"usage\": {\n \"prompt_tokens\": 25,\n \"completion_tokens\": + 2,\n \"total_tokens\": 27\n },\n \"system_fingerprint\": \"fp_3aa7262c27\"\n}\n" + headers: {} + status: + code: 200 + message: OK +version: 1 diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/__init__.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/_known_gaps.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/_known_gaps.py new file mode 100644 index 000000000..3fed46f8c --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/_known_gaps.py @@ -0,0 +1,43 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Shared ``ExpectedViolation`` declarations for gaps that recur across +several conformance scenarios in this package. See the package README's +"Known limitations" section for the full rationale behind each. +""" + +from __future__ import annotations + +from opentelemetry.test_util_genai.conformance import ExpectedViolation + +# Haystack's OpenAIChatGenerator (_convert_chat_completion_to_chat_message) +# never copies the OpenAI response's `id` into the ChatMessage it builds, and +# `run()` returns no other place to find it -- the response id is genuinely +# unrecoverable from this instrumentation, not just unpopulated. +MISSING_RESPONSE_ID = ExpectedViolation( + "genai_expected_attribute_missing", "gen_ai.response.id" +) + +# Haystack's SDK-backed generators/embedders construct their underlying SDK +# client lazily (`self.client`/`self.async_client` start as None) via +# `warm_up()`, which `Pipeline.run()` calls automatically before running its +# components -- so server.address/port is already populated for +# Pipeline-driven calls. A component called *standalone* (not through a +# Pipeline) only gets it starting on the instance's second call, since +# nothing else triggers warm_up() first. Every standalone-call conformance +# scenario constructs a fresh instance and calls it exactly once, so this is +# unavoidable here without instrumentation code forcing early client +# construction (e.g. calling `component.warm_up()` ourselves) -- deliberately +# not done, since warm_up() also warms up any configured tools, which for +# some Tool/Toolset implementations (e.g. an MCP-backed Toolset) can mean +# arbitrary, instrumentation-inappropriate I/O. +MISSING_SERVER_ADDRESS = ExpectedViolation( + "genai_expected_attribute_missing", "server.address" +) + +# tool_call.id correlation would require hooking the private +# haystack.components.agents.tool_calling._make_context_bound_invoke -- see +# patch.py's Tool.invoke wrapper comment. +MISSING_TOOL_CALL_ID = ExpectedViolation( + "genai_expected_attribute_missing", "gen_ai.tool.call.id" +) diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/embedding.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/embedding.py new file mode 100644 index 000000000..c99484bf9 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/embedding.py @@ -0,0 +1,53 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Conformance scenario: OpenAIDocumentEmbedder.run (embeddings).""" + +from __future__ import annotations + +from typing import Any + +from haystack import Document +from haystack.components.embedders.openai_document_embedder import ( + OpenAIDocumentEmbedder, +) + +from opentelemetry.instrumentation.genai.haystack import HaystackInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.test_util_genai.conformance import Scenario +from opentelemetry.test_util_genai.instrumentor import instrument + +from ._known_gaps import MISSING_SERVER_ADDRESS + + +class EmbeddingScenario(Scenario): + expected_spans = {"embeddings": 1} + expected_metrics = ("gen_ai.client.operation.duration",) + expected_violations = (MISSING_SERVER_ADDRESS,) + + def run( + self, + *, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, + logger_provider: LoggerProvider, + vcr: Any, + ) -> None: + with instrument( + HaystackInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="SPAN_ONLY", + ): + with vcr.use_cassette("embedding_conformance.yaml"): + OpenAIDocumentEmbedder(model="text-embedding-3-small").run( + documents=[ + Document( + content="Argentina won the World Cup in 2022." + ), + Document(content="France won the World Cup in 2018."), + ] + ) diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/inference.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/inference.py new file mode 100644 index 000000000..265233740 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/inference.py @@ -0,0 +1,59 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Conformance scenario: OpenAIChatGenerator.run (chat inference).""" + +from __future__ import annotations + +from typing import Any + +from haystack.components.generators.chat.openai import OpenAIChatGenerator +from haystack.dataclasses.chat_message import ChatMessage + +from opentelemetry.instrumentation.genai.haystack import HaystackInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.test_util_genai.conformance import Scenario +from opentelemetry.test_util_genai.instrumentor import instrument + +from ._known_gaps import MISSING_RESPONSE_ID, MISSING_SERVER_ADDRESS + + +class InferenceScenario(Scenario): + expected_spans = {"chat": 1} + expected_metrics = ( + "gen_ai.client.operation.duration", + "gen_ai.client.token.usage", + ) + expected_violations = (MISSING_RESPONSE_ID, MISSING_SERVER_ADDRESS) + + def run( + self, + *, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, + logger_provider: LoggerProvider, + vcr: Any, + ) -> None: + with instrument( + HaystackInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="SPAN_ONLY", + ): + with vcr.use_cassette("inference_conformance.yaml"): + OpenAIChatGenerator(model="gpt-4o").run( + messages=[ + ChatMessage.from_system( + "Answer user questions succinctly" + ), + ChatMessage.from_assistant( + "What can I help you with?" + ), + ChatMessage.from_user( + "Who won the World Cup in 2022? Answer in one word." + ), + ] + ) diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/invoke_agent.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/invoke_agent.py new file mode 100644 index 000000000..b5e666418 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/invoke_agent.py @@ -0,0 +1,120 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Conformance scenario: an Agent run that calls a tool. + +Uses a fake, local, deterministic ``ChatGenerator`` (scripted to request a +tool call, then answer) and a real ``Tool`` -- no HTTP interaction, no +cassette needed. Exercises ``invoke_agent`` plus the nested ``chat`` / +``execute_tool`` spans the Agent drives. +""" + +from __future__ import annotations + +from typing import Any, List + +from haystack import component +from haystack.components.agents.agent import Agent +from haystack.dataclasses.chat_message import ChatMessage, ToolCall +from haystack.tools import Tool + +from opentelemetry.instrumentation.genai.haystack import HaystackInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.test_util_genai.conformance import Scenario +from opentelemetry.test_util_genai.instrumentor import instrument + +from ._known_gaps import MISSING_SERVER_ADDRESS, MISSING_TOOL_CALL_ID + + +@component +class _ScriptedChatGenerator: + def __init__(self) -> None: + self.model = "scripted-model" + self._call_count = 0 + + @component.output_types(replies=List[ChatMessage]) + def run( + self, + messages: Any, # noqa: ARG002 + tools: Any = None, # noqa: ARG002 + generation_kwargs: Any = None, # noqa: ARG002 + **kwargs: Any, # noqa: ARG002 + ) -> dict[str, Any]: + self._call_count += 1 + if self._call_count == 1: + reply = ChatMessage.from_assistant( + text=None, + tool_calls=[ + ToolCall( + tool_name="get_weather", + arguments={"city": "Berlin"}, + id="call_1", + ) + ], + meta={ + "id": "scripted-response-1", + "model": "scripted-model", + "finish_reason": "tool_calls", + "usage": {"prompt_tokens": 10, "completion_tokens": 5}, + }, + ) + else: + reply = ChatMessage.from_assistant( + text="It is sunny in Berlin.", + meta={ + "id": "scripted-response-2", + "model": "scripted-model", + "finish_reason": "stop", + "usage": {"prompt_tokens": 20, "completion_tokens": 8}, + }, + ) + return {"replies": [reply]} + + +def _get_weather(city: str) -> str: + return f"sunny in {city}" + + +class InvokeAgentScenario(Scenario): + expected_spans = {"invoke_agent": 1, "chat": 2, "execute_tool": 1} + expected_metrics = ("gen_ai.client.operation.duration",) + # _ScriptedChatGenerator is a bare test fake with no SDK client at all, + # so server.address is never available for it (not just a cold-start + # timing issue, unlike the real OpenAI-backed scenarios). + expected_violations = (MISSING_SERVER_ADDRESS, MISSING_TOOL_CALL_ID) + + def run( + self, + *, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, + logger_provider: LoggerProvider, + vcr: Any, # noqa: ARG002 - unused; no HTTP interaction + ) -> None: + with instrument( + HaystackInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="SPAN_ONLY", + ): + tool = Tool( + name="get_weather", + description="Get the weather for a city", + parameters={ + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + function=_get_weather, + ) + agent = Agent( + chat_generator=_ScriptedChatGenerator(), tools=[tool] + ) + agent.run( + messages=[ + ChatMessage.from_user("What's the weather in Berlin?") + ] + ) diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/invoke_workflow.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/invoke_workflow.py new file mode 100644 index 000000000..9d702aa0d --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/invoke_workflow.py @@ -0,0 +1,73 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Conformance scenario: Pipeline.run wrapping a chat-generator component.""" + +from __future__ import annotations + +from typing import Any + +from haystack import Pipeline +from haystack.components.builders.chat_prompt_builder import ChatPromptBuilder +from haystack.components.generators.chat.openai import OpenAIChatGenerator +from haystack.dataclasses.chat_message import ChatMessage + +from opentelemetry.instrumentation.genai.haystack import HaystackInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.test_util_genai.conformance import Scenario +from opentelemetry.test_util_genai.instrumentor import instrument + +from ._known_gaps import MISSING_RESPONSE_ID + + +class WorkflowScenario(Scenario): + expected_spans = {"invoke_workflow": 1, "chat": 1} + expected_metrics = ( + "gen_ai.client.operation.duration", + "gen_ai.client.token.usage", + ) + # Unlike the standalone-call scenarios, Pipeline.run() calls warm_up() on + # its components before running them, so the chat generator's SDK client + # (and therefore server.address) is already constructed by the time our + # wrapper runs -- no server.address gap here. + expected_violations = (MISSING_RESPONSE_ID,) + + def run( + self, + *, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, + logger_provider: LoggerProvider, + vcr: Any, + ) -> None: + with instrument( + HaystackInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="SPAN_ONLY", + ): + with vcr.use_cassette("workflow_conformance.yaml"): + pipeline = Pipeline() + pipeline.add_component("prompt_builder", ChatPromptBuilder()) + pipeline.add_component( + "llm", OpenAIChatGenerator(model="gpt-4o") + ) + pipeline.connect("prompt_builder.prompt", "llm.messages") + pipeline.run( + data={ + "prompt_builder": { + "template_variables": {"location": "Berlin"}, + "template": [ + ChatMessage.from_system( + "Answer concisely in one sentence." + ), + ChatMessage.from_user( + "What country is {{location}} in?" + ), + ], + } + } + ) diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/retrieval.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/retrieval.py new file mode 100644 index 000000000..265d74e9e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/retrieval.py @@ -0,0 +1,63 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Conformance scenario: InMemoryBM25Retriever.run (retrieval). + +Local/deterministic component -- no HTTP interaction, no cassette needed. +""" + +from __future__ import annotations + +from typing import Any + +from haystack import Document +from haystack.components.retrievers.in_memory.bm25_retriever import ( + InMemoryBM25Retriever, +) +from haystack.document_stores.in_memory.document_store import ( + InMemoryDocumentStore, +) + +from opentelemetry.instrumentation.genai.haystack import HaystackInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.test_util_genai.conformance import Scenario +from opentelemetry.test_util_genai.instrumentor import instrument + +from ._known_gaps import MISSING_SERVER_ADDRESS + + +class RetrievalScenario(Scenario): + expected_spans = {"retrieval": 1} + expected_metrics = ("gen_ai.client.operation.duration",) + # Unlike the generator/embedder scenarios, this isn't a lazy-client + # timing issue -- InMemoryDocumentStore has no server at all. + expected_violations = (MISSING_SERVER_ADDRESS,) + + def run( + self, + *, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, + logger_provider: LoggerProvider, + vcr: Any, # noqa: ARG002 - unused; component makes no HTTP calls + ) -> None: + with instrument( + HaystackInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="SPAN_ONLY", + ): + store = InMemoryDocumentStore() + store.write_documents( + [ + Document( + content="Use pip to install Haystack's latest release" + ) + ] + ) + InMemoryBM25Retriever(document_store=store).run( + query="install Haystack", top_k=1 + ) diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/tool_calling.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/tool_calling.py new file mode 100644 index 000000000..8485ff807 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conformance/tool_calling.py @@ -0,0 +1,103 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Conformance scenario: a chat turn where the model returns a tool call. + +Asserts the tool call round-trips onto the output message's ``tool_call`` +part; weaver validates the part's shape. +""" + +from __future__ import annotations + +import json +from typing import Any + +from haystack.components.generators.chat.openai import OpenAIChatGenerator +from haystack.dataclasses.chat_message import ChatMessage + +from opentelemetry.instrumentation.genai.haystack import HaystackInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.test.weaver_live_check import LiveCheckReport +from opentelemetry.test_util_genai.conformance import Scenario +from opentelemetry.test_util_genai.instrumentor import instrument + +from ._known_gaps import MISSING_RESPONSE_ID, MISSING_SERVER_ADDRESS + +_WEATHER_TOOL = { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "Get the current weather", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + } + }, + "required": ["location"], + }, + }, +} + + +class ToolCallingScenario(Scenario): + expected_spans = {"chat": 1} + expected_metrics = ("gen_ai.client.operation.duration",) + expected_violations = (MISSING_RESPONSE_ID, MISSING_SERVER_ADDRESS) + + def run( + self, + *, + tracer_provider: TracerProvider, + meter_provider: MeterProvider, + logger_provider: LoggerProvider, + vcr: Any, + ) -> None: + with instrument( + HaystackInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="SPAN_ONLY", + ): + with vcr.use_cassette("tool_calling_conformance.yaml"): + OpenAIChatGenerator(model="gpt-4o").run( + messages=[ + ChatMessage.from_user("What is the weather in Berlin") + ], + generation_kwargs={"tools": [_WEATHER_TOOL]}, + ) + + def validate(self, report: LiveCheckReport) -> None: + super().validate(report) + chat_spans = [ + entry["span"] + for entry in report["samples"] + if "span" in entry + and _attr(entry["span"], "gen_ai.operation.name") == "chat" + ] + assert chat_spans, "no chat span emitted" + output_part_types = { + t + for span in chat_spans + for t in _part_types(_attr(span, "gen_ai.output.messages")) + } + assert "tool_call" in output_part_types, ( + f"expected a tool_call part on an output message, saw {output_part_types}" + ) + + +def _attr(span: dict[str, Any], name: str) -> Any: + for attr in span["attributes"]: + if attr["name"] == name: + return attr["value"] + return None + + +def _part_types(messages_json: str | None) -> list[str]: + messages = json.loads(messages_json) if messages_json else [] + return [part["type"] for message in messages for part in message["parts"]] diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conftest.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conftest.py new file mode 100644 index 000000000..14a977c6e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/conftest.py @@ -0,0 +1,84 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Test configuration and fixtures for Haystack instrumentation tests.""" +# pylint: disable=redefined-outer-name + +import os + +import pytest + +from opentelemetry.instrumentation.genai.haystack import HaystackInstrumentor +from opentelemetry.test_util_genai.instrumentor import instrument +from opentelemetry.test_util_genai.vcr import scrub_response_headers + +pytest_plugins = [ + "opentelemetry.test_util_genai.fixtures", + "opentelemetry.test_util_genai.vcr", +] + + +@pytest.fixture(autouse=True) +def environment(): + """Set up environment variables for testing.""" + if not os.getenv("OPENAI_API_KEY"): + os.environ["OPENAI_API_KEY"] = "test_openai_api_key" + # Haystack pings deepset's telemetry endpoint on first Pipeline.run() / + # component import unless disabled; not something this instrumentation + # should be recording or waiting on network for in tests. + os.environ["HAYSTACK_TELEMETRY_ENABLED"] = "False" + + +@pytest.fixture(scope="module") +def vcr_config(): + """Configure VCR for recording/replaying HTTP interactions.""" + return { + "filter_headers": [ + ("authorization", "Bearer test_openai_api_key"), + ("openai-organization", "test_openai_org_id"), + ], + "decode_compressed_response": True, + "before_record_response": scrub_response_headers( + ["openai-organization", "set-cookie"] + ), + } + + +@pytest.fixture +def instrument_no_content(tracer_provider, logger_provider, meter_provider): + """Instrument Haystack without content capture.""" + with instrument( + HaystackInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="NO_CONTENT", + ) as instrumentor: + yield instrumentor + + +@pytest.fixture +def instrument_with_content(tracer_provider, logger_provider, meter_provider): + """Instrument Haystack with ``SPAN_ONLY`` content capture.""" + with instrument( + HaystackInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="SPAN_ONLY", + ) as instrumentor: + yield instrumentor + + +@pytest.fixture +def instrument_event_only(tracer_provider, logger_provider, meter_provider): + """Instrument Haystack with ``EVENT_ONLY`` content capture.""" + with instrument( + HaystackInstrumentor(), + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + content_capture="EVENT_ONLY", + emit_event=True, + ) as instrumentor: + yield instrumentor diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/requirements.latest.txt b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/requirements.latest.txt new file mode 100644 index 000000000..3c465d0d1 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/requirements.latest.txt @@ -0,0 +1,44 @@ +# Copyright The OpenTelemetry Authors +# +# 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. + + +# ******************************** +# WARNING: NOT HERMETIC !!!!!!!!!! +# ******************************** +# +# This "requirements.txt" is installed in conjunction +# with multiple other dependencies in the top-level "tox.ini" +# file. In particular, please see: +# +# haystack-latest: {[testenv]test_deps} +# haystack-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/requirements.latest.txt +# +# This provides additional dependencies, namely: +# +# opentelemetry-api +# opentelemetry-sdk +# opentelemetry-semantic-conventions +# opentelemetry-instrumentation +# +# ... with a "dev" version based on the latest distribution. + + +# This variant of the requirements aims to test the system using +# the newest supported version of external dependencies. + +haystack-ai +# test with the latest version of opentelemetry-api, sdk, semantic conventions, and instrumentation + +-e util/opentelemetry-util-genai +-e instrumentation/opentelemetry-instrumentation-genai-haystack diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/requirements.oldest.txt b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/requirements.oldest.txt new file mode 100644 index 000000000..596846e40 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/requirements.oldest.txt @@ -0,0 +1,26 @@ +# Copyright The OpenTelemetry Authors +# +# 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. + +# Oldest test-only dependency pins. +# +# The package's own declared deps (haystack-ai via the instruments extra, +# opentelemetry-api, opentelemetry-instrumentation, opentelemetry-semantic-conventions, +# opentelemetry-util-genai) are resolved to their pyproject.toml floors by +# UV_RESOLUTION=lowest-direct on the oldest tox factor, so they are NOT pinned here -- +# pyproject.toml is the single source of truth. `openai` (used by the OpenAI-backed +# components exercised in tests) is a direct haystack-ai dependency and comes in +# transitively. +# +# There is currently nothing to pin: haystack has no test-only dependency that isn't +# already provided by a declared bound or by the shared test fixtures. diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_agent.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_agent.py new file mode 100644 index 000000000..995bc8d07 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_agent.py @@ -0,0 +1,180 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the ``Agent`` component -> ``AgentInvocation``. + +Drives a real ``haystack.components.agents.agent.Agent`` end to end against +a fake, local, deterministic ``ChatGenerator`` and a real ``Tool`` -- no +network calls needed. The Agent's own tool-calling turn also exercises the +already-covered ``chat`` (``test_inference.py``) and ``execute_tool`` +(``test_tool.py``) code paths as nested spans; this file only asserts on +the outer ``invoke_agent`` span and the overall span tree. +""" + +from typing import List + +from haystack import component +from haystack.components.agents.agent import Agent +from haystack.dataclasses.chat_message import ChatMessage, ToolCall +from haystack.tools import Tool + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + +from .test_utils import load_messages_attribute, message_part_types + + +@component +class _ScriptedChatGenerator: + """Returns a tool call on the first turn, a final answer on the second.""" + + def __init__(self): + self.model = "scripted-model" + self._call_count = 0 + + @component.output_types(replies=List[ChatMessage]) + def run(self, messages, tools=None, generation_kwargs=None, **kwargs): + self._call_count += 1 + if self._call_count == 1: + reply = ChatMessage.from_assistant( + text=None, + tool_calls=[ + ToolCall( + tool_name="get_weather", + arguments={"city": "Berlin"}, + id="call_1", + ) + ], + meta={ + "id": "scripted-response-1", + "model": "scripted-model", + "finish_reason": "tool_calls", + "usage": {"prompt_tokens": 10, "completion_tokens": 5}, + }, + ) + else: + reply = ChatMessage.from_assistant( + text="It is sunny in Berlin.", + meta={ + "id": "scripted-response-2", + "model": "scripted-model", + "finish_reason": "stop", + "usage": {"prompt_tokens": 20, "completion_tokens": 8}, + }, + ) + return {"replies": [reply]} + + +def _get_weather(city: str) -> str: + return f"sunny in {city}" + + +def _build_agent() -> Agent: + tool = Tool( + name="get_weather", + description="Get the weather for a city", + parameters={ + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + function=_get_weather, + ) + return Agent(chat_generator=_ScriptedChatGenerator(), tools=[tool]) + + +def test_agent_run_produces_nested_chat_tool_and_agent_spans( + span_exporter, instrument_with_content +): + agent = _build_agent() + result = agent.run( + messages=[ChatMessage.from_user("What's the weather in Berlin?")] + ) + assert result["last_message"].text == "It is sunny in Berlin." + + spans = span_exporter.get_finished_spans() + assert [span.name for span in spans] == [ + "chat scripted-model", + "execute_tool get_weather", + "chat scripted-model", + "invoke_agent Agent", + ] + chat_span_1, tool_span, chat_span_2, agent_span = spans + + # All three inner spans nest directly under the agent span. + for inner in (chat_span_1, tool_span, chat_span_2): + assert inner.parent is not None + assert inner.parent.span_id == agent_span.context.span_id + + assert agent_span.status.is_ok + agent_attributes = agent_span.attributes or {} + assert ( + agent_attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] + == "invoke_agent" + ) + assert agent_attributes[GenAIAttributes.GEN_AI_AGENT_NAME] == "Agent" + + input_messages = load_messages_attribute( + agent_span, GenAIAttributes.GEN_AI_INPUT_MESSAGES + ) + assert len(input_messages) == 1 + assert input_messages[0]["role"] == "user" + + # Output messages are only the newly generated ones -- the tool call, + # the tool result, and the final answer -- not the echoed-back input. + output_messages = load_messages_attribute( + agent_span, GenAIAttributes.GEN_AI_OUTPUT_MESSAGES + ) + assert len(output_messages) == 3 + assert "tool_call" in message_part_types(output_messages[0]) + assert "tool_call_response" in message_part_types(output_messages[1]) + assert output_messages[2]["role"] == "assistant" + + +def test_agent_run_no_content_capture(span_exporter, instrument_no_content): + agent = _build_agent() + agent.run( + messages=[ChatMessage.from_user("What's the weather in Berlin?")] + ) + + agent_span = next( + span + for span in span_exporter.get_finished_spans() + if span.name == "invoke_agent Agent" + ) + attributes = agent_span.attributes or {} + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES not in attributes + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in attributes + assert attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] == "invoke_agent" + + +def test_component_defined_after_instrument_is_still_wrapped( + span_exporter, instrument_with_content +): + """Regression test: a component class defined *after* ``instrument()`` + runs (the common "instrument first, build pipeline second" ordering) + must still be classified and wrapped -- not just ones already imported + at instrumentation time. See __init__.py's ``_Component._component`` + registration hook. + """ + + @component + class _LateGenerator: + @component.output_types(replies=List[ChatMessage]) + def run(self, messages, **kwargs): + return { + "replies": [ + ChatMessage.from_assistant( + "late", meta={"finish_reason": "stop"} + ) + ] + } + + _LateGenerator().run(messages=[ChatMessage.from_user("hi")]) + + (span,) = span_exporter.get_finished_spans() + assert span.name == "chat" + assert (span.attributes or {})[ + GenAIAttributes.GEN_AI_OPERATION_NAME + ] == "chat" diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_conformance.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_conformance.py new file mode 100644 index 000000000..3d5c2629f --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_conformance.py @@ -0,0 +1,37 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +pytest.importorskip("opentelemetry.test.weaver_live_check") + +from opentelemetry.test.weaver_live_check import WeaverLiveCheck # noqa: E402 +from opentelemetry.test_util_genai.conformance import ( # noqa: E402 + Scenario, + run_conformance, +) + +from .conformance.embedding import EmbeddingScenario # noqa: E402 +from .conformance.inference import InferenceScenario # noqa: E402 +from .conformance.invoke_agent import InvokeAgentScenario # noqa: E402 +from .conformance.invoke_workflow import WorkflowScenario # noqa: E402 +from .conformance.retrieval import RetrievalScenario # noqa: E402 +from .conformance.tool_calling import ToolCallingScenario # noqa: E402 + + +@pytest.mark.parametrize( + "scenario", + [ + InferenceScenario(), + EmbeddingScenario(), + RetrievalScenario(), + ToolCallingScenario(), + WorkflowScenario(), + InvokeAgentScenario(), + ], + ids=lambda s: type(s).__name__, +) +def test_conformance( + scenario: Scenario, vcr, weaver_live_check: WeaverLiveCheck +) -> None: + run_conformance(scenario, vcr=vcr, weaver=weaver_live_check) diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_embedding.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_embedding.py new file mode 100644 index 000000000..d1a39adf8 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_embedding.py @@ -0,0 +1,47 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for classified ``EMBEDDER`` components -> ``EmbeddingInvocation``. + +``EmbeddingInvocation`` only carries aggregate request/response metadata +(``dimension_count``, ``input_tokens``, ``response_model_name``) — there is +no util-genai field for per-document embedded text or vectors. +""" + +import pytest +from haystack import Document +from haystack.components.embedders.openai_document_embedder import ( + OpenAIDocumentEmbedder, +) + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + +OPENAI = GenAIAttributes.GenAiProviderNameValues.OPENAI.value + + +@pytest.mark.vcr +def test_document_embedder(span_exporter, instrument_with_content): + embedder = OpenAIDocumentEmbedder(model="text-embedding-3-small") + documents = [ + Document(content="Argentina won the World Cup in 2022."), + Document(content="France won the World Cup in 2018."), + ] + response = embedder.run(documents=documents) + embedded_documents = response["documents"] + assert len(embedded_documents) == 2 + assert embedded_documents[0].embedding is not None + + (span,) = span_exporter.get_finished_spans() + assert span.name == "embeddings text-embedding-3-small" + attributes = span.attributes or {} + assert attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] == "embeddings" + assert ( + attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL] + == "text-embedding-3-small" + ) + assert attributes[GenAIAttributes.GEN_AI_PROVIDER_NAME] == OPENAI + assert attributes[ + GenAIAttributes.GEN_AI_EMBEDDINGS_DIMENSION_COUNT + ] == len(embedded_documents[0].embedding) diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_inference.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_inference.py new file mode 100644 index 000000000..9c2e1118d --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_inference.py @@ -0,0 +1,213 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for classified ``GENERATOR`` components -> ``InferenceInvocation``. + +Components are called directly (not through a ``Pipeline``) to isolate the +component-level wrapping from ``Pipeline.run``'s workflow span — the two are +covered together in ``test_workflow.py``. +""" + +import json + +import pytest +from haystack.components.generators.chat.openai import OpenAIChatGenerator +from haystack.dataclasses.chat_message import ChatMessage +from haystack.utils import Secret + +from opentelemetry.semconv._incubating.attributes import ( + error_attributes as ErrorAttributes, +) +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + +from .test_utils import ( + assert_chat_span_attributes, + load_messages_attribute, + message_part_types, + text_content, +) + +OPENAI = GenAIAttributes.GenAiProviderNameValues.OPENAI.value + + +@pytest.mark.vcr +def test_chat_generator_sync(span_exporter, instrument_with_content): + generator = OpenAIChatGenerator(model="gpt-4o") + messages = [ + ChatMessage.from_system("Answer user questions succinctly"), + ChatMessage.from_assistant("What can I help you with?"), + ChatMessage.from_user( + "Who won the World Cup in 2022? Answer in one word." + ), + ] + response = generator.run(messages=messages) + assert response["replies"][0].text == "Argentina." + + (span,) = span_exporter.get_finished_spans() + assert_chat_span_attributes( + span, + request_model="gpt-4o", + provider=OPENAI, + response_model="gpt-4o-2024-05-13", + input_tokens=42, + output_tokens=2, + finish_reasons=("stop",), + ) + + input_messages = load_messages_attribute( + span, GenAIAttributes.GEN_AI_INPUT_MESSAGES + ) + assert [message["role"] for message in input_messages] == [ + "system", + "assistant", + "user", + ] + assert ( + text_content(input_messages[2]) + == "Who won the World Cup in 2022? Answer in one word." + ) + + output_messages = load_messages_attribute( + span, GenAIAttributes.GEN_AI_OUTPUT_MESSAGES + ) + assert len(output_messages) == 1 + assert output_messages[0]["role"] == "assistant" + assert text_content(output_messages[0]) == "Argentina." + assert output_messages[0]["finish_reason"] == "stop" + + +@pytest.mark.vcr +async def test_chat_generator_async(span_exporter, instrument_with_content): + generator = OpenAIChatGenerator(model="gpt-4o") + messages = [ + ChatMessage.from_system("Answer user questions succinctly"), + ChatMessage.from_assistant("What can I help you with?"), + ChatMessage.from_user( + "Who won the World Cup in 2022? Answer in one word." + ), + ] + response = await generator.run_async(messages=messages) + assert response["replies"][0].text == "Argentina." + + (span,) = span_exporter.get_finished_spans() + assert_chat_span_attributes( + span, + request_model="gpt-4o", + provider=OPENAI, + response_model="gpt-4o-2024-08-06", + input_tokens=42, + output_tokens=2, + ) + + +@pytest.mark.vcr +def test_chat_generator_no_content_capture( + span_exporter, instrument_no_content +): + generator = OpenAIChatGenerator(model="gpt-4o") + messages = [ + ChatMessage.from_system("Answer user questions succinctly"), + ChatMessage.from_assistant("What can I help you with?"), + ChatMessage.from_user( + "Who won the World Cup in 2022? Answer in one word." + ), + ] + generator.run(messages=messages) + + (span,) = span_exporter.get_finished_spans() + attributes = span.attributes or {} + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES not in attributes + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in attributes + # Non-content attributes are still recorded. + assert attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL] == "gpt-4o" + assert attributes[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] == 42 + + +@pytest.mark.vcr +def test_chat_generator_error(span_exporter, instrument_with_content): + generator = OpenAIChatGenerator( + model="gpt-4o", api_key=Secret.from_token("sk-invalid") + ) + with pytest.raises(Exception) as excinfo: + generator.run( + messages=[ + ChatMessage.from_user( + "Who won the World Cup in 2022? Answer in one word." + ) + ] + ) + + err_name_fq = ( + f"{type(excinfo.value).__module__}.{type(excinfo.value).__name__}" + ) + err_name_short = type(excinfo.value).__name__ + + (span,) = span_exporter.get_finished_spans() + assert not span.status.is_ok + attributes = span.attributes or {} + assert attributes[ErrorAttributes.ERROR_TYPE] in ( + err_name_fq, + err_name_short, + ) + assert attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL] == "gpt-4o" + + +@pytest.mark.vcr +def test_tool_calling_captures_tool_call_on_output_message( + span_exporter, instrument_with_content +): + generator = OpenAIChatGenerator(model="gpt-4o") + response = generator.run( + messages=[ChatMessage.from_user("What is the weather in Berlin")], + generation_kwargs={ + "tools": [ + { + "type": "function", + "function": { + "name": "get_current_weather", + "description": "Get the current weather", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "The city and state, e.g. San Francisco, CA", + } + }, + "required": ["location"], + }, + }, + }, + ] + }, + ) + reply = response["replies"][0] + assert reply.tool_calls[0].tool_name == "get_current_weather" + + (span,) = span_exporter.get_finished_spans() + assert_chat_span_attributes( + span, + request_model="gpt-4o", + provider=OPENAI, + response_model="gpt-4o-2024-05-13", + input_tokens=63, + output_tokens=15, + finish_reasons=("tool_calls",), + ) + + output_messages = load_messages_attribute( + span, GenAIAttributes.GEN_AI_OUTPUT_MESSAGES + ) + assert "tool_call" in message_part_types(output_messages[0]) + tool_call_part = next( + part + for part in output_messages[0]["parts"] + if part["type"] == "tool_call" + ) + assert tool_call_part["name"] == "get_current_weather" + arguments = tool_call_part["arguments"] + if isinstance(arguments, str): + arguments = json.loads(arguments) + assert arguments == {"location": "Berlin"} diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_instrumentor.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_instrumentor.py new file mode 100644 index 000000000..5e6fd8f3e --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_instrumentor.py @@ -0,0 +1,67 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Sanity tests for the ``HaystackInstrumentor`` itself: entry point, and +that instrument/uninstrument actually wrap and unwrap the methods this +package documents.""" + +import haystack +from haystack.components.agents.agent import Agent +from haystack.components.generators.chat.openai import OpenAIChatGenerator +from haystack.core.component.component import _Component +from haystack.tools import Tool + +from opentelemetry.instrumentation.genai.haystack import HaystackInstrumentor +from opentelemetry.util._importlib_metadata import entry_points + + +def test_entrypoint_for_opentelemetry_instrument(): + (instrumentor_entrypoint,) = entry_points( + group="opentelemetry_instrumentor", name="haystack" + ) + instrumentor = instrumentor_entrypoint.load()() + assert isinstance(instrumentor, HaystackInstrumentor) + + +def test_instrument_and_uninstrument_wrap_and_unwrap_expected_methods( + tracer_provider, +): + original_pipeline_run = haystack.Pipeline.run + original_pipeline_run_async = haystack.Pipeline.run_async + original_pipeline_run_async_generator = ( + haystack.Pipeline.run_async_generator + ) + original_component_register = _Component._component + original_chat_generator_run = OpenAIChatGenerator.run + original_agent_run = Agent.run + original_tool_invoke = Tool.invoke + original_tool_invoke_async = Tool.invoke_async + + instrumentor = HaystackInstrumentor() + instrumentor.instrument(tracer_provider=tracer_provider) + try: + assert haystack.Pipeline.run is not original_pipeline_run + assert haystack.Pipeline.run_async is not original_pipeline_run_async + assert ( + haystack.Pipeline.run_async_generator + is not original_pipeline_run_async_generator + ) + assert _Component._component is not original_component_register + assert OpenAIChatGenerator.run is not original_chat_generator_run + assert Agent.run is not original_agent_run + assert Tool.invoke is not original_tool_invoke + assert Tool.invoke_async is not original_tool_invoke_async + finally: + instrumentor.uninstrument() + + assert haystack.Pipeline.run == original_pipeline_run + assert haystack.Pipeline.run_async == original_pipeline_run_async + assert ( + haystack.Pipeline.run_async_generator + == original_pipeline_run_async_generator + ) + assert _Component._component == original_component_register + assert OpenAIChatGenerator.run == original_chat_generator_run + assert Agent.run == original_agent_run + assert Tool.invoke == original_tool_invoke + assert Tool.invoke_async == original_tool_invoke_async diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_retrieval.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_retrieval.py new file mode 100644 index 000000000..1f1ae8f10 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_retrieval.py @@ -0,0 +1,96 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for classified ``RETRIEVER`` / ``RANKER`` components -> ``RetrievalInvocation``. + +Both retrievers and rankers use built-in, local, deterministic Haystack +components (BM25 keyword search, metadata-field ranking) — no network calls, +no VCR cassette needed. +""" + +import json + +from haystack import Document +from haystack.components.rankers.meta_field import MetaFieldRanker +from haystack.components.retrievers.in_memory.bm25_retriever import ( + InMemoryBM25Retriever, +) +from haystack.document_stores.in_memory.document_store import ( + InMemoryDocumentStore, +) + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + + +def test_bm25_retriever(span_exporter, instrument_with_content): + store = InMemoryDocumentStore() + store.write_documents( + [ + Document(content="Use pip to install Haystack's latest release"), + Document(content="Argentina won the World Cup in 2022"), + ] + ) + retriever = InMemoryBM25Retriever(document_store=store) + result = retriever.run(query="How do I install Haystack?", top_k=1) + documents = result["documents"] + assert len(documents) == 1 + assert "install" in documents[0].content.lower() + + (span,) = span_exporter.get_finished_spans() + assert span.name == "retrieval" + attributes = span.attributes or {} + assert attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] == "retrieval" + assert attributes[GenAIAttributes.GEN_AI_REQUEST_TOP_K] == 1.0 + assert ( + attributes[GenAIAttributes.GEN_AI_RETRIEVAL_QUERY_TEXT] + == "How do I install Haystack?" + ) + + retrieved_documents = json.loads( + attributes[GenAIAttributes.GEN_AI_RETRIEVAL_DOCUMENTS] + ) + assert len(retrieved_documents) == 1 + assert retrieved_documents[0]["id"] == documents[0].id + assert retrieved_documents[0]["content"] == documents[0].content + + +def test_bm25_retriever_no_content_capture( + span_exporter, instrument_no_content +): + store = InMemoryDocumentStore() + store.write_documents( + [Document(content="Use pip to install Haystack's latest release")] + ) + retriever = InMemoryBM25Retriever(document_store=store) + retriever.run(query="install", top_k=1) + + (span,) = span_exporter.get_finished_spans() + attributes = span.attributes or {} + assert GenAIAttributes.GEN_AI_RETRIEVAL_QUERY_TEXT not in attributes + assert GenAIAttributes.GEN_AI_RETRIEVAL_DOCUMENTS not in attributes + assert attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] == "retrieval" + + +def test_meta_field_ranker(span_exporter, instrument_with_content): + ranker = MetaFieldRanker(meta_field="rating") + documents = [ + Document(content="low rated", meta={"rating": 1}), + Document(content="high rated", meta={"rating": 5}), + ] + result = ranker.run(documents=documents) + ranked_documents = result["documents"] + assert ranked_documents[0].content == "high rated" + + (span,) = span_exporter.get_finished_spans() + assert span.name == "retrieval" + attributes = span.attributes or {} + assert attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] == "retrieval" + retrieved_documents = json.loads( + attributes[GenAIAttributes.GEN_AI_RETRIEVAL_DOCUMENTS] + ) + assert [doc["content"] for doc in retrieved_documents] == [ + "high rated", + "low rated", + ] diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_tool.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_tool.py new file mode 100644 index 000000000..6f2100f63 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_tool.py @@ -0,0 +1,117 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for ``haystack.tools.Tool.invoke`` / ``invoke_async`` -> ``ToolInvocation``. + +``tool_call_id`` correlation is not populated -- see patch.py's Tool.invoke +wrapper comment for why. +""" + +import json + +import pytest +from haystack.tools import Tool + +from opentelemetry.semconv._incubating.attributes import ( + error_attributes as ErrorAttributes, +) +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + + +def _get_weather(city: str) -> str: + return f"sunny in {city}" + + +def _failing_tool(**kwargs): + raise RuntimeError("boom") + + +def _weather_tool() -> Tool: + return Tool( + name="get_weather", + description="Get the weather for a city", + parameters={ + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + function=_get_weather, + ) + + +def test_tool_invoke_sync(span_exporter, instrument_with_content): + tool = _weather_tool() + result = tool.invoke(city="Berlin") + assert result == "sunny in Berlin" + + (span,) = span_exporter.get_finished_spans() + assert span.name == "execute_tool get_weather" + attributes = span.attributes or {} + assert attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] == "execute_tool" + assert attributes[GenAIAttributes.GEN_AI_TOOL_NAME] == "get_weather" + assert attributes[GenAIAttributes.GEN_AI_TOOL_TYPE] == "function" + assert ( + attributes[GenAIAttributes.GEN_AI_TOOL_DESCRIPTION] + == "Get the weather for a city" + ) + assert json.loads( + attributes[GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS] + ) == {"city": "Berlin"} + # A plain string result is a valid AttributeValue on its own and is not + # JSON-encoded (unlike `arguments`, a dict, which is). + assert ( + attributes[GenAIAttributes.GEN_AI_TOOL_CALL_RESULT] + == "sunny in Berlin" + ) + + +async def test_tool_invoke_async(span_exporter, instrument_with_content): + tool = _weather_tool() + result = await tool.invoke_async(city="Paris") + assert result == "sunny in Paris" + + (span,) = span_exporter.get_finished_spans() + assert span.name == "execute_tool get_weather" + attributes = span.attributes or {} + assert attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] == "execute_tool" + assert json.loads( + attributes[GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS] + ) == {"city": "Paris"} + + +def test_tool_invoke_no_content_capture(span_exporter, instrument_no_content): + tool = _weather_tool() + tool.invoke(city="Berlin") + + (span,) = span_exporter.get_finished_spans() + attributes = span.attributes or {} + assert GenAIAttributes.GEN_AI_TOOL_CALL_ARGUMENTS not in attributes + assert GenAIAttributes.GEN_AI_TOOL_CALL_RESULT not in attributes + assert attributes[GenAIAttributes.GEN_AI_TOOL_NAME] == "get_weather" + + +def test_tool_invoke_error(span_exporter, instrument_with_content): + tool = Tool( + name="failing_tool", + description="Always fails", + parameters={"type": "object", "properties": {}}, + function=_failing_tool, + ) + with pytest.raises(Exception) as excinfo: + tool.invoke() + + err_name_fq = ( + f"{type(excinfo.value).__module__}.{type(excinfo.value).__name__}" + ) + err_name_short = type(excinfo.value).__name__ + + (span,) = span_exporter.get_finished_spans() + assert not span.status.is_ok + attributes = span.attributes or {} + assert attributes[ErrorAttributes.ERROR_TYPE] in ( + err_name_fq, + err_name_short, + ) + assert attributes[GenAIAttributes.GEN_AI_TOOL_NAME] == "failing_tool" diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_utils.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_utils.py new file mode 100644 index 000000000..5697b3d77 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_utils.py @@ -0,0 +1,85 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Shared assertion helpers for Haystack instrumentation tests.""" + +from __future__ import annotations + +import json +from typing import Any, Mapping, Sequence + +from opentelemetry.sdk.trace import ReadableSpan +from opentelemetry.semconv._incubating.attributes import ( + error_attributes as ErrorAttributes, +) +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + + +def assert_chat_span_attributes( # pylint: disable=too-many-arguments + span: ReadableSpan, + *, + request_model: str, + operation_name: str = "chat", + provider: str | None = None, + response_model: str | None = None, + input_tokens: int | None = None, + output_tokens: int | None = None, + finish_reasons: Sequence[str] | None = None, +) -> None: + attributes = span.attributes or {} + assert span.name == f"{operation_name} {request_model}" + assert attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] == operation_name + assert attributes[GenAIAttributes.GEN_AI_REQUEST_MODEL] == request_model + if provider is not None: + assert attributes[GenAIAttributes.GEN_AI_PROVIDER_NAME] == provider + if response_model is not None: + assert ( + attributes[GenAIAttributes.GEN_AI_RESPONSE_MODEL] == response_model + ) + if input_tokens is not None: + assert ( + attributes[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] + == input_tokens + ) + if output_tokens is not None: + assert ( + attributes[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] + == output_tokens + ) + if finish_reasons is not None: + assert ( + tuple(finish_reasons) + == attributes[GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS] + ) + + +def assert_error_recorded(span: ReadableSpan, error_type: str) -> None: + assert not span.status.is_ok + assert (span.attributes or {}).get( + ErrorAttributes.ERROR_TYPE + ) == error_type + + +def load_messages_attribute( + span: ReadableSpan, attribute: str +) -> list[Mapping[str, Any]]: + value = (span.attributes or {}).get(attribute) + assert isinstance(value, str), ( + f"expected {attribute} to be a JSON string, got {value!r}" + ) + parsed = json.loads(value) + assert isinstance(parsed, list) + return parsed + + +def message_part_types(message: Mapping[str, Any]) -> list[str]: + return [part["type"] for part in message["parts"]] + + +def text_content(message: Mapping[str, Any]) -> str | None: + for part in message["parts"]: + if part["type"] == "text": + return part["content"] + return None diff --git a/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_workflow.py b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_workflow.py new file mode 100644 index 000000000..5bab3c690 --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_workflow.py @@ -0,0 +1,157 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for ``Pipeline.run`` / ``Pipeline.run_async`` -> ``invoke_workflow``.""" + +from typing import List + +import pytest +from haystack import Pipeline, component +from haystack.components.builders.chat_prompt_builder import ChatPromptBuilder +from haystack.components.generators.chat.openai import OpenAIChatGenerator +from haystack.dataclasses.chat_message import ChatMessage + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + +from .test_utils import assert_chat_span_attributes + + +@component +class _EchoGenerator: + @component.output_types(replies=List[ChatMessage]) + def run(self, messages, **kwargs): + return { + "replies": [ + ChatMessage.from_assistant( + "ok", meta={"finish_reason": "stop"} + ) + ] + } + + +@pytest.mark.vcr +def test_pipeline_run_produces_workflow_and_chat_spans( + span_exporter, instrument_with_content +): + """A prompt-builder + chat-generator pipeline yields exactly the spans this + migration supports: one for the classified generator, one for the + pipeline itself. ``ChatPromptBuilder`` has no util-genai invocation type + and produces no span of its own.""" + pipeline = Pipeline() + prompt_builder = ChatPromptBuilder() + llm = OpenAIChatGenerator(model="gpt-4o") + pipeline.add_component("prompt_builder", prompt_builder) + pipeline.add_component("llm", llm) + pipeline.connect("prompt_builder.prompt", "llm.messages") + + messages = [ + ChatMessage.from_system("Answer concisely in one sentence."), + ChatMessage.from_user("What country is {{location}} in?"), + ] + pipeline.run( + data={ + "prompt_builder": { + "template_variables": {"location": "Berlin"}, + "template": messages, + } + } + ) + + spans = span_exporter.get_finished_spans() + assert [span.name for span in spans] == [ + "chat gpt-4o", + "invoke_workflow Pipeline", + ] + + chat_span, workflow_span = spans + assert chat_span.status.is_ok + assert_chat_span_attributes( + chat_span, + request_model="gpt-4o", + provider=GenAIAttributes.GenAiProviderNameValues.OPENAI.value, + response_model="gpt-4o-2024-05-13", + input_tokens=25, + output_tokens=2, + finish_reasons=("stop",), + ) + assert chat_span.parent is not None + assert chat_span.parent.span_id == workflow_span.context.span_id + + assert workflow_span.status.is_ok + workflow_attributes = workflow_span.attributes or {} + assert ( + workflow_attributes[GenAIAttributes.GEN_AI_OPERATION_NAME] + == "invoke_workflow" + ) + + +@pytest.mark.vcr +async def test_pipeline_run_async_produces_workflow_and_chat_spans( + span_exporter, instrument_with_content +): + pipeline = Pipeline() + llm = OpenAIChatGenerator(model="gpt-4o") + pipeline.add_component("llm", llm) + + messages = [ + ChatMessage.from_system("Answer user questions succinctly"), + ChatMessage.from_assistant("What can I help you with?"), + ChatMessage.from_user( + "Who won the World Cup in 2022? Answer in one word." + ), + ] + await pipeline.run_async(data={"llm": {"messages": messages}}) + + spans = span_exporter.get_finished_spans() + assert [span.name for span in spans] == [ + "chat gpt-4o", + "invoke_workflow Pipeline", + ] + + chat_span, workflow_span = spans + assert_chat_span_attributes( + chat_span, + request_model="gpt-4o", + response_model="gpt-4o-2024-08-06", + input_tokens=42, + output_tokens=2, + ) + assert chat_span.parent.span_id == workflow_span.context.span_id + + +async def test_run_async_generator_called_directly_gets_its_own_workflow_span( + span_exporter, instrument_no_content +): + """A caller draining ``run_async_generator()`` directly (rather than + through ``run_async()``) still gets exactly one ``invoke_workflow`` + span -- not zero (unwrapped) and not two (double-counted).""" + pipeline = Pipeline() + pipeline.add_component("llm", _EchoGenerator()) + + async for _ in pipeline.run_async_generator( + {"llm": {"messages": [ChatMessage.from_user("hi")]}} + ): + pass + + spans = span_exporter.get_finished_spans() + workflow_spans = [s for s in spans if s.name.startswith("invoke_workflow")] + assert len(workflow_spans) == 1 + + +async def test_run_async_does_not_double_count_inner_generator( + span_exporter, instrument_no_content +): + """``run_async()`` drains ``run_async_generator()`` internally -- that + inner call must not produce a second ``invoke_workflow`` span.""" + pipeline = Pipeline() + pipeline.add_component("llm", _EchoGenerator()) + + await pipeline.run_async( + {"llm": {"messages": [ChatMessage.from_user("hi")]}} + ) + + spans = span_exporter.get_finished_spans() + workflow_spans = [s for s in spans if s.name.startswith("invoke_workflow")] + assert len(workflow_spans) == 1 diff --git a/tox.ini b/tox.ini index 55d61bfaf..7bbf7bca4 100644 --- a/tox.ini +++ b/tox.ini @@ -71,6 +71,12 @@ envlist = py3{12,13}-test-instrumentation-genai-qwen-agent-conformance lint-instrumentation-genai-qwen-agent + ; instrumentation-genai-haystack + py3{10,11,12,13,14}-test-instrumentation-genai-haystack-latest + py310-test-instrumentation-genai-haystack-oldest + py314-test-instrumentation-genai-haystack-conformance + lint-instrumentation-genai-haystack + ; instrumentation-genai-weaviate-client ; TODO: write tests (tests/ is empty), add tests/requirements.{oldest,latest}.txt ; and weaviate-{oldest,latest} factors below. @@ -102,9 +108,9 @@ pytest_deps = pytest>=8 pytest-asyncio>=0.24 pytest-vcr>=1.0.2 - -e {toxinidir}/util/opentelemetry-test-util-genai + -e util/opentelemetry-test-util-genai deps = - conformance: -r {toxinidir}/dev-requirements-conformance.txt + conformance: -r dev-requirements-conformance.txt lint: -r dev-requirements.txt coverage: pytest coverage: pytest-cov @@ -112,40 +118,40 @@ deps = # packages that are released individually should provide a tests/requirements.{oldest,latest}.txt # with the lowest version of OTel API and SDK supported (oldest) and the latest one. openai-oldest: {[testenv]pytest_deps} - openai-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai[instruments] - openai-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai/tests/requirements.oldest.txt + openai-oldest: -e instrumentation/opentelemetry-instrumentation-genai-openai[instruments] + openai-oldest: -r instrumentation/opentelemetry-instrumentation-genai-openai/tests/requirements.oldest.txt openai-latest: {[testenv]test_deps} openai-latest: {[testenv]pytest_deps} - openai-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai/tests/requirements.latest.txt + openai-latest: -r instrumentation/opentelemetry-instrumentation-genai-openai/tests/requirements.latest.txt openai-conformance: {[testenv]pytest_deps} - openai-conformance: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai/tests/requirements.latest.txt + openai-conformance: -r instrumentation/opentelemetry-instrumentation-genai-openai/tests/requirements.latest.txt openai_agents-oldest: {[testenv]pytest_deps} - openai_agents-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai-agents[instruments] - openai_agents-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/requirements.oldest.txt + openai_agents-oldest: -e instrumentation/opentelemetry-instrumentation-genai-openai-agents[instruments] + openai_agents-oldest: -r instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/requirements.oldest.txt openai_agents-latest: {[testenv]test_deps} openai_agents-latest: {[testenv]pytest_deps} - openai_agents-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/requirements.latest.txt + openai_agents-latest: -r instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/requirements.latest.txt openai_agents-conformance: {[testenv]pytest_deps} - openai_agents-conformance: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/requirements.latest.txt + openai_agents-conformance: -r instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/requirements.latest.txt google-genai-oldest: {[testenv]pytest_deps} - google-genai-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-google-genai[instruments] - google-genai-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt + google-genai-oldest: -e instrumentation/opentelemetry-instrumentation-google-genai[instruments] + google-genai-oldest: -r instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.oldest.txt google-genai-latest: {[testenv]test_deps} google-genai-latest: {[testenv]pytest_deps} - google-genai-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.latest.txt + google-genai-latest: -r instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.latest.txt google-genai-conformance: {[testenv]pytest_deps} - google-genai-conformance: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.latest.txt + google-genai-conformance: -r instrumentation/opentelemetry-instrumentation-google-genai/tests/requirements.latest.txt agno-oldest: {[testenv]pytest_deps} - agno-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-agno[instruments] - agno-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-agno/tests/requirements.oldest.txt + agno-oldest: -e instrumentation/opentelemetry-instrumentation-genai-agno[instruments] + agno-oldest: -r instrumentation/opentelemetry-instrumentation-genai-agno/tests/requirements.oldest.txt agno-latest: {[testenv]test_deps} agno-latest: {[testenv]pytest_deps} - agno-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-agno/tests/requirements.latest.txt + agno-latest: -r instrumentation/opentelemetry-instrumentation-genai-agno/tests/requirements.latest.txt agno-conformance: {[testenv]pytest_deps} - agno-conformance: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-agno/tests/requirements.latest.txt + agno-conformance: -r instrumentation/opentelemetry-instrumentation-genai-agno/tests/requirements.latest.txt smolagents-oldest: {[testenv]pytest_deps} smolagents-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-smolagents[instruments] @@ -155,20 +161,20 @@ deps = smolagents-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-smolagents/tests/requirements.latest.txt anthropic-oldest: {[testenv]pytest_deps} - anthropic-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-anthropic[instruments] - anthropic-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/requirements.oldest.txt + anthropic-oldest: -e instrumentation/opentelemetry-instrumentation-genai-anthropic[instruments] + anthropic-oldest: -r instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/requirements.oldest.txt anthropic-latest: {[testenv]test_deps} anthropic-latest: {[testenv]pytest_deps} - anthropic-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/requirements.latest.txt + anthropic-latest: -r instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/requirements.latest.txt anthropic-conformance: {[testenv]pytest_deps} - anthropic-conformance: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/requirements.latest.txt + anthropic-conformance: -r instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/requirements.latest.txt claude-agent-sdk-oldest: {[testenv]pytest_deps} - claude-agent-sdk-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk[instruments] - claude-agent-sdk-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/tests/requirements.oldest.txt + claude-agent-sdk-oldest: -e instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk[instruments] + claude-agent-sdk-oldest: -r instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/tests/requirements.oldest.txt claude-agent-sdk-latest: {[testenv]test_deps} claude-agent-sdk-latest: {[testenv]pytest_deps} - claude-agent-sdk-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/tests/requirements.latest.txt + claude-agent-sdk-latest: -r instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/tests/requirements.latest.txt qwen-agent-oldest: {[testenv]pytest_deps} qwen-agent-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-qwen-agent[instruments] @@ -179,19 +185,28 @@ deps = qwen-agent-conformance: {[testenv]pytest_deps} qwen-agent-conformance: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/requirements.latest.txt + haystack-oldest: {[testenv]pytest_deps} + haystack-oldest: -e instrumentation/opentelemetry-instrumentation-genai-haystack[instruments] + haystack-oldest: -r instrumentation/opentelemetry-instrumentation-genai-haystack/tests/requirements.oldest.txt + haystack-latest: {[testenv]test_deps} + haystack-latest: {[testenv]pytest_deps} + haystack-latest: -r instrumentation/opentelemetry-instrumentation-genai-haystack/tests/requirements.latest.txt + haystack-conformance: {[testenv]pytest_deps} + haystack-conformance: -r instrumentation/opentelemetry-instrumentation-genai-haystack/tests/requirements.latest.txt + ; Langchain unit tests langchain-{oldest,latest}: {[testenv]pytest_deps} ; oldest: declared deps (opentelemetry-instrumentation, util-genai, langchain) resolve from PyPI to ; their pyproject floors via UV_RESOLUTION=lowest-direct; only provider test libs are pinned by hand. - langchain-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-langchain[instruments] - langchain-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/requirements.oldest.txt + langchain-oldest: -e instrumentation/opentelemetry-instrumentation-genai-langchain[instruments] + langchain-oldest: -r instrumentation/opentelemetry-instrumentation-genai-langchain/tests/requirements.oldest.txt langchain-latest: {[testenv]test_deps} - langchain-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/requirements.latest.txt + langchain-latest: -r instrumentation/opentelemetry-instrumentation-genai-langchain/tests/requirements.latest.txt ; Langchain conformance tests langchain-conformance: {[testenv]pytest_deps} - langchain-conformance: -e {toxinidir}/util/opentelemetry-util-genai - langchain-conformance: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-langchain[instruments] + langchain-conformance: -e util/opentelemetry-util-genai + langchain-conformance: -e instrumentation/opentelemetry-instrumentation-genai-langchain[instruments] langchain-conformance: langchain-openai langchain-conformance: langchain-anthropic langchain-conformance: langchain-aws @@ -199,23 +214,23 @@ deps = langchain-conformance: boto3 llama_index-oldest: {[testenv]pytest_deps} - llama_index-oldest: -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-llama-index[instruments] - llama_index-oldest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/requirements.oldest.txt + llama_index-oldest: -e instrumentation/opentelemetry-instrumentation-genai-llama-index[instruments] + llama_index-oldest: -r instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/requirements.oldest.txt llama_index-latest: {[testenv]test_deps} llama_index-latest: {[testenv]pytest_deps} - llama_index-latest: -r {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/requirements.latest.txt + llama_index-latest: -r instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/requirements.latest.txt util-genai: {[testenv]test_deps} util-genai: {[testenv]pytest_deps} - util-genai: -r {toxinidir}/util/opentelemetry-util-genai/test-requirements.txt - util-genai: -e {toxinidir}/util/opentelemetry-util-genai + util-genai: -r util/opentelemetry-util-genai/test-requirements.txt + util-genai: -e util/opentelemetry-util-genai allowlist_externals = sh pytest setenv = - UV_CONFIG_FILE={toxinidir}/tox-uv.toml + UV_CONFIG_FILE=tox-uv.toml ; oldest env installs the lowest versions of the package's direct declared deps straight from ; pyproject.toml, so the declared lower bounds are the single source of truth (no hand pins to drift). ; Only the instrumentation package under test is installed editable (`-e ...[instruments]`); its @@ -226,39 +241,43 @@ setenv = commands_pre = ; In order to get a health coverage report, ; we have to install packages in editable mode. - coverage: python {toxinidir}/scripts/eachdist.py install --editable + coverage: python scripts/eachdist.py install --editable commands = - test-instrumentation-genai-openai-{oldest,latest}: pytest --ignore={toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_conformance.py {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai/tests {posargs} - test-instrumentation-genai-openai-conformance: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_conformance.py --vcr-record=none {posargs} + test-instrumentation-genai-openai-{oldest,latest}: pytest --ignore=instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_conformance.py instrumentation/opentelemetry-instrumentation-genai-openai/tests {posargs} + test-instrumentation-genai-openai-conformance: pytest instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_conformance.py --vcr-record=none {posargs} lint-instrumentation-genai-openai: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-openai" - test-instrumentation-genai-openai_agents-{oldest,latest}: pytest --ignore={toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_conformance.py {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests {posargs} - test-instrumentation-genai-openai_agents-conformance: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_conformance.py --vcr-record=none {posargs} + test-instrumentation-genai-openai_agents-{oldest,latest}: pytest --ignore=instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_conformance.py instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests {posargs} + test-instrumentation-genai-openai_agents-conformance: pytest instrumentation/opentelemetry-instrumentation-genai-openai-agents/tests/test_conformance.py --vcr-record=none {posargs} lint-instrumentation-genai-openai_agents: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-openai-agents" - test-instrumentation-google-genai-{oldest,latest}: pytest --ignore={toxinidir}/instrumentation/opentelemetry-instrumentation-google-genai/tests/test_conformance.py {toxinidir}/instrumentation/opentelemetry-instrumentation-google-genai/tests --vcr-record=none {posargs} - test-instrumentation-google-genai-conformance: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-google-genai/tests/test_conformance.py --vcr-record=none {posargs} + test-instrumentation-google-genai-{oldest,latest}: pytest --ignore=instrumentation/opentelemetry-instrumentation-google-genai/tests/test_conformance.py instrumentation/opentelemetry-instrumentation-google-genai/tests --vcr-record=none {posargs} + test-instrumentation-google-genai-conformance: pytest instrumentation/opentelemetry-instrumentation-google-genai/tests/test_conformance.py --vcr-record=none {posargs} lint-instrumentation-google-genai: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-google-genai" - test-instrumentation-genai-agno-{oldest,latest}: pytest --ignore={toxinidir}/instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_conformance.py {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-agno/tests --vcr-record=none {posargs} - test-instrumentation-genai-agno-conformance: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_conformance.py --vcr-record=none {posargs} + test-instrumentation-genai-agno-{oldest,latest}: pytest --ignore=instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_conformance.py instrumentation/opentelemetry-instrumentation-genai-agno/tests --vcr-record=none {posargs} + test-instrumentation-genai-agno-conformance: pytest instrumentation/opentelemetry-instrumentation-genai-agno/tests/test_conformance.py --vcr-record=none {posargs} lint-instrumentation-genai-agno: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-agno" - test-instrumentation-genai-smolagents-{oldest,latest}: pytest --ignore={toxinidir}/instrumentation/opentelemetry-instrumentation-genai-smolagents/tests/test_conformance.py {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-smolagents/tests --vcr-record=none {posargs} + test-instrumentation-genai-smolagents-{oldest,latest}: pytest --ignore=instrumentation/opentelemetry-instrumentation-genai-smolagents/tests/test_conformance.py instrumentation/opentelemetry-instrumentation-genai-smolagents/tests --vcr-record=none {posargs} lint-instrumentation-genai-smolagents: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-smolagents" - test-instrumentation-genai-anthropic-{oldest,latest}: pytest --ignore={toxinidir}/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_conformance.py {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests --vcr-record=none {posargs} - test-instrumentation-genai-anthropic-conformance: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_conformance.py --vcr-record=none {posargs} + test-instrumentation-genai-anthropic-{oldest,latest}: pytest --ignore=instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_conformance.py instrumentation/opentelemetry-instrumentation-genai-anthropic/tests --vcr-record=none {posargs} + test-instrumentation-genai-anthropic-conformance: pytest instrumentation/opentelemetry-instrumentation-genai-anthropic/tests/test_conformance.py --vcr-record=none {posargs} lint-instrumentation-genai-anthropic: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-anthropic" - test-instrumentation-genai-claude-agent-sdk: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/tests --vcr-record=none {posargs} + test-instrumentation-genai-claude-agent-sdk: pytest instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk/tests --vcr-record=none {posargs} lint-instrumentation-genai-claude-agent-sdk: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-claude-agent-sdk" - test-instrumentation-genai-langchain-{oldest,latest}: pytest --ignore={toxinidir}/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_conformance.py {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-langchain/tests --vcr-record=none {posargs} - test-instrumentation-genai-langchain-conformance: pytest {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_conformance.py --vcr-record=none {posargs} + test-instrumentation-genai-haystack-{oldest,latest}: pytest --ignore=instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_conformance.py instrumentation/opentelemetry-instrumentation-genai-haystack/tests --vcr-record=none {posargs} + test-instrumentation-genai-haystack-conformance: pytest instrumentation/opentelemetry-instrumentation-genai-haystack/tests/test_conformance.py --vcr-record=none {posargs} + lint-instrumentation-genai-haystack: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-haystack" + + test-instrumentation-genai-langchain-{oldest,latest}: pytest --ignore=instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_conformance.py instrumentation/opentelemetry-instrumentation-genai-langchain/tests --vcr-record=none {posargs} + test-instrumentation-genai-langchain-conformance: pytest instrumentation/opentelemetry-instrumentation-genai-langchain/tests/test_conformance.py --vcr-record=none {posargs} lint-instrumentation-genai-langchain: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-langchain" - test-instrumentation-genai-llama_index-{oldest,latest}: pytest --ignore={toxinidir}/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/test_conformance.py {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-llama-index/tests --vcr-record=none {posargs} + test-instrumentation-genai-llama_index-{oldest,latest}: pytest --ignore=instrumentation/opentelemetry-instrumentation-genai-llama-index/tests/test_conformance.py instrumentation/opentelemetry-instrumentation-genai-llama-index/tests --vcr-record=none {posargs} lint-instrumentation-genai-llama_index: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-llama-index" test-instrumentation-genai-qwen-agent-{oldest,latest}: pytest --ignore={toxinidir}/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests/test_conformance.py {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-qwen-agent/tests --vcr-record=none {posargs} @@ -267,18 +286,18 @@ commands = lint-instrumentation-genai-weaviate-client: sh -c "cd instrumentation && ruff check opentelemetry-instrumentation-genai-weaviate-client" - test-util-genai: pytest {toxinidir}/util/opentelemetry-util-genai/tests {posargs} + test-util-genai: pytest util/opentelemetry-util-genai/tests {posargs} lint-util-genai: sh -c "cd util && ruff check opentelemetry-util-genai" - coverage: {toxinidir}/scripts/coverage.sh + coverage: scripts/coverage.sh [testenv:docs] deps = - -c {toxinidir}/dev-requirements.txt - -r {toxinidir}/docs-requirements.txt + -c dev-requirements.txt + -r docs-requirements.txt pytest {[testenv]test_deps} - {toxinidir}/util/opentelemetry-util-genai + util/opentelemetry-util-genai changedir = docs @@ -296,22 +315,22 @@ commands = [testenv:generate] commands = - python {toxinidir}/scripts/generate_instrumentation_readme.py + python scripts/generate_instrumentation_readme.py [testenv:readme] description = Check each package README.rst renders on PyPI (matches twine check). deps = - -c {toxinidir}/dev-requirements.txt + -c dev-requirements.txt readme-renderer allowlist_externals = sh commands = - sh -c 'python {toxinidir}/scripts/check_for_valid_readme.py -v \ - {toxinidir}/instrumentation/*/ \ - {toxinidir}/util/opentelemetry-util-genai' + sh -c 'python scripts/check_for_valid_readme.py -v \ + instrumentation/*/ \ + util/opentelemetry-util-genai' [testenv:lint-license-header-check] commands = - python {toxinidir}/scripts/check_license_header.py + python scripts/check_license_header.py [testenv:changelog-preview] description = Render a draft changelog for every package that uses towncrier. @@ -321,7 +340,7 @@ allowlist_externals = sh commands = sh -c '\ set -eu; \ - for pkg in {toxinidir}/instrumentation/* {toxinidir}/util/opentelemetry-util-genai; do \ + for pkg in instrumentation/* util/opentelemetry-util-genai; do \ [ -f "$pkg/pyproject.toml" ] || continue; \ grep -q "tool.towncrier" "$pkg/pyproject.toml" || continue; \ echo "=== $pkg ==="; \ @@ -334,32 +353,33 @@ commands_pre = sh -c "sudo apt update -y && sudo apt install --assume-yes shellcheck" commands = - sh -c "find {toxinidir} -name \*.sh | xargs shellcheck --severity=warning" + sh -c "git ls-files '*.sh' | xargs shellcheck --severity=warning" [testenv:{precommit,ruff}] basepython: python3 deps = - -c {toxinidir}/dev-requirements.txt + -c dev-requirements.txt pre-commit commands = pre-commit run --color=always --all-files {posargs} [testenv:typecheck] deps = - -c {toxinidir}/dev-requirements.txt + -c dev-requirements.txt pyright {[testenv]test_deps} - -e {toxinidir}/util/opentelemetry-util-genai[upload] - -e {toxinidir}/instrumentation/opentelemetry-instrumentation-google-genai[instruments] - -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-agno[instruments] - -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-smolagents[instruments] - -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-anthropic[instruments] - -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-langchain[instruments] - -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-llama-index[instruments] - -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk[instruments] - -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-openai-agents[instruments] - -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-qwen-agent[instruments] - -e {toxinidir}/instrumentation/opentelemetry-instrumentation-genai-weaviate-client[instruments] + -e util/opentelemetry-util-genai[upload] + -e instrumentation/opentelemetry-instrumentation-google-genai[instruments] + -e instrumentation/opentelemetry-instrumentation-genai-agno[instruments] + -e instrumentation/opentelemetry-instrumentation-genai-smolagents[instruments] + -e instrumentation/opentelemetry-instrumentation-genai-anthropic[instruments] + -e instrumentation/opentelemetry-instrumentation-genai-langchain[instruments] + -e instrumentation/opentelemetry-instrumentation-genai-llama-index[instruments] + -e instrumentation/opentelemetry-instrumentation-genai-claude-agent-sdk[instruments] + -e instrumentation/opentelemetry-instrumentation-genai-haystack[instruments] + -e instrumentation/opentelemetry-instrumentation-genai-openai-agents[instruments] + -e instrumentation/opentelemetry-instrumentation-genai-qwen-agent[instruments] + -e instrumentation/opentelemetry-instrumentation-genai-weaviate-client[instruments] commands = pyright diff --git a/uv.lock b/uv.lock index e687c1b8e..31a1b42ae 100644 --- a/uv.lock +++ b/uv.lock @@ -17,6 +17,7 @@ members = [ "opentelemetry-instrumentation-genai-agno", "opentelemetry-instrumentation-genai-anthropic", "opentelemetry-instrumentation-genai-claude-agent-sdk", + "opentelemetry-instrumentation-genai-haystack", "opentelemetry-instrumentation-genai-langchain", "opentelemetry-instrumentation-genai-llama-index", "opentelemetry-instrumentation-genai-openai", @@ -328,6 +329,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, ] +[[package]] +name = "backoff" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, +] + [[package]] name = "banks" version = "2.4.5" @@ -1178,6 +1188,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, ] +[[package]] +name = "haystack-ai" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docstring-parser" }, + { name = "filetype" }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "lazy-imports" }, + { name = "markupsafe" }, + { name = "more-itertools" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "openai" }, + { name = "posthog" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/f5b82c7bd90345abfff69a76445ee63e9b42f362303946a97bfd5cf49e08/haystack_ai-3.0.0.tar.gz", hash = "sha256:c948a337e7a53d9bc47f3c08c2ad5e52ca6bd44956ad6d9e3512209a056493b4", size = 469683, upload-time = "2026-07-20T12:07:00.615Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/3f/fa387315618f0c2dcac7178b781476363b7b9edbb0e02c23eb5a60a2392d/haystack_ai-3.0.0-py3-none-any.whl", hash = "sha256:523718f200b27e11c8e33acf29fc8608c2f9951a0877eedf11bf838d06abff05", size = 656076, upload-time = "2026-07-20T12:06:58.875Z" }, +] + [[package]] name = "hf-xet" version = "1.5.2" @@ -1641,6 +1683,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ed/54/509a1eb6b9e5572a4f3f4b087779d240c6b69f3d5247ffa21314f66155d9/langsmith-0.10.9-py3-none-any.whl", hash = "sha256:5e0e8ab0f8df05710809919184495e33c2a7c9a9a5e8861d63dd12c1226d9c79", size = 673326, upload-time = "2026-07-20T21:27:11.889Z" }, ] +[[package]] +name = "lazy-imports" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/67/04432aae0c1e2729bff14e1841f4a3fb63a9e354318e66622251487760c3/lazy_imports-1.2.0.tar.gz", hash = "sha256:3c546b3c1e7c4bf62a07f897f6179d9feda6118e71ef6ecc47a339cab3d2e2d9", size = 24470, upload-time = "2025-12-28T13:51:51.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/62/60ed24fa8707f10c1c5aef94791252b820be3dd6bdfc6e2fcdb08bc8912f/lazy_imports-1.2.0-py3-none-any.whl", hash = "sha256:97134d6552e2ba16f1a278e316f05313ab73b360e848e40d593d08a5c2406fdf", size = 18681, upload-time = "2025-12-28T13:51:49.802Z" }, +] + [[package]] name = "llama-index-core" version = "0.14.23" @@ -1853,6 +1904,15 @@ 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 = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -2409,6 +2469,31 @@ requires-dist = [ ] provides-extras = ["instruments"] +[[package]] +name = "opentelemetry-instrumentation-genai-haystack" +source = { editable = "instrumentation/opentelemetry-instrumentation-genai-haystack" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-genai" }, +] + +[package.optional-dependencies] +instruments = [ + { name = "haystack-ai" }, +] + +[package.metadata] +requires-dist = [ + { name = "haystack-ai", marker = "extra == 'instruments'", specifier = ">=3.0.0" }, + { name = "opentelemetry-api", specifier = "~=1.43" }, + { name = "opentelemetry-instrumentation", specifier = ">=0.64b0,<1" }, + { name = "opentelemetry-semantic-conventions", specifier = ">=0.64b0,<1" }, + { name = "opentelemetry-util-genai", editable = "util/opentelemetry-util-genai" }, +] +provides-extras = ["instruments"] + [[package]] name = "opentelemetry-instrumentation-genai-langchain" source = { editable = "instrumentation/opentelemetry-instrumentation-genai-langchain" } @@ -3024,6 +3109,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "posthog" +version = "7.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backoff" }, + { name = "distro" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/e8/6bb8cf7ce8642ab59e797c31d34abebc4fe723c1e85caa2426e5b1c469ff/posthog-7.37.0.tar.gz", hash = "sha256:b905556227014c8451616daeb4c14c4691ae7c21b68737b5e68a524acc39a535", size = 399017, upload-time = "2026-08-03T19:36:35.239Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/63/17eda217707047a9f7ffacdb648bb4438439a01e578e21a44a194a15d5cc/posthog-7.37.0-py3-none-any.whl", hash = "sha256:4381fc921a10a4d08e95d8a5da196c7dadb33586731eafdc75df30ad2db3dff4", size = 472269, upload-time = "2026-08-03T19:36:33.75Z" }, +] + [[package]] name = "pre-commit" version = "4.6.1" @@ -3415,6 +3515,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-discovery" version = "1.5.0" @@ -4008,6 +4120,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "smolagents" version = "1.26.0"