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\":
+ \"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\"\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\":
+ \"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\"\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"