diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/.gitignore b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/.gitignore
new file mode 100644
index 0000000..b6abc5e
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/.gitignore
@@ -0,0 +1,3 @@
+examples/.env
+examples/openai_agents_multi_agent_travel/.env
+examples/**/.env
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/CHANGELOG.md b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/CHANGELOG.md
new file mode 100644
index 0000000..95f69d6
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/CHANGELOG.md
@@ -0,0 +1,20 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## Unreleased
+- Document official package metadata and README for the OpenAI Agents instrumentation.
+ ([#3859](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3859))
+
+## Version 0.1.0 (2025-10-15)
+
+- Initial barebones package skeleton: minimal instrumentor stub, version module,
+ and packaging metadata/entry point.
+ ([#3805](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3805))
+- Implement OpenAI Agents span processing aligned with GenAI semantic conventions.
+ ([#3817](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3817))
+- Input and output according to GenAI spec.
+ ([#3824](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3824))
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/LICENSE b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/LICENSE
new file mode 100644
index 0000000..d483259
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/LICENSE
@@ -0,0 +1,201 @@
+Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity 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
+ (which shall not include communication that is clearly marked or
+ otherwise designated in writing by the copyright owner as "Not a Work").
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based upon (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 clearly 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 use, reproduce, modify, distribute, and 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, trademark, patent,
+ attribution and other 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 notice 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 Support. When 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 support.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/README.rst
new file mode 100644
index 0000000..2f7c81e
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/README.rst
@@ -0,0 +1,109 @@
+OpenTelemetry OpenAI Agents Instrumentation
+===========================================
+
+|pypi|
+
+.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-openai-agents-v2.svg
+ :target: https://pypi.org/project/opentelemetry-instrumentation-openai-agents-v2/
+
+This library provides the official OpenTelemetry instrumentation for the
+`openai-agents SDK `_. It converts
+the rich trace data emitted by the Agents runtime
+into the GenAI semantic conventions, enriches spans with request/response payload
+metadata, and records duration/token usage metrics.
+
+Features
+--------
+
+* Generates spans for agents, tools, generations, guardrails, and handoffs using
+ the OpenTelemetry GenAI semantic conventions.
+* Captures prompts, responses, tool arguments, and system instructions when content
+ capture is enabled.
+* Publishes duration and token metrics for every operation.
+* Supports environment overrides so you can configure agent metadata or disable
+ telemetry without code changes.
+
+Installation
+------------
+
+If your application is already configured with OpenTelemetry, install the package
+and its optional instruments:
+
+.. code-block:: console
+
+ pip install opentelemetry-instrumentation-openai-agents-v2
+ pip install openai-agents
+
+Usage
+-----
+
+Instrumentation automatically wires the Agents tracing processor into the SDK.
+Configure OpenTelemetry as usual, then call :class:`OpenAIAgentsInstrumentor`.
+
+.. code-block:: python
+
+ from agents import Agent, Runner, function_tool
+ from opentelemetry import trace
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
+ from opentelemetry.instrumentation.openai_agents import OpenAIAgentsInstrumentor
+ from opentelemetry.sdk.trace import TracerProvider
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor
+
+
+ def configure_otel() -> None:
+ provider = TracerProvider()
+ provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
+ trace.set_tracer_provider(provider)
+
+ OpenAIAgentsInstrumentor().instrument(tracer_provider=provider)
+
+
+ @function_tool
+ def get_weather(city: str) -> str:
+ return f"The forecast for {city} is sunny with pleasant temperatures."
+
+
+ assistant = Agent(
+ name="Travel Concierge",
+ instructions="You are a concise travel concierge.",
+ tools=[get_weather],
+ )
+
+ result = Runner.run_sync(assistant, "I'm visiting Barcelona this weekend. How should I pack?")
+ print(result.final_output)
+
+Configuration
+-------------
+
+The instrumentor exposes runtime toggles through keyword arguments and environment
+variables:
+
+* ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` or
+ ``OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_CONTENT`` – controls how much
+ message content is captured. Valid values map to
+ :class:`opentelemetry.instrumentation.openai_agents.ContentCaptureMode`
+ (``span_only``, ``event_only``, ``span_and_event``, ``no_content``).
+* ``OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_METRICS`` – set to ``false`` to
+ disable duration/token metrics.
+* ``OTEL_INSTRUMENTATION_OPENAI_AGENTS_SYSTEM`` – overrides the ``gen_ai.system``
+ attribute when your deployment is not the default OpenAI platform.
+
+You can also override agent metadata directly when calling
+``OpenAIAgentsInstrumentor().instrument(...)`` using ``agent_name``, ``agent_id``,
+``agent_description``, ``base_url``, ``server_address``, and ``server_port``.
+
+Examples
+--------
+
+The :mod:`examples` directory contains runnable scenarios, including:
+
+* ``examples/manual`` – manual OpenTelemetry configuration for a single agent run.
+* ``examples/content-capture`` – demonstrates span and event content capture.
+* ``examples/zero-code`` – end-to-end setup using environment configuration only.
+
+References
+----------
+
+* `OpenTelemetry Python Contrib `_
+* `OpenTelemetry GenAI semantic conventions `_
+* `OpenAI Agents SDK `_
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/content-capture/.env.example b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/content-capture/.env.example
new file mode 100644
index 0000000..97060c4
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/content-capture/.env.example
@@ -0,0 +1,8 @@
+# Copy to .env and add values before running the sample.
+# Required for OpenAI client (only used if you swap in a real OpenAI call)
+OPENAI_API_KEY=
+
+# Optional overrides for span attributes / exporters
+OTEL_SERVICE_NAME=openai-agents-content-capture-demo
+OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
+OTEL_EXPORTER_OTLP_PROTOCOL=grpc
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/content-capture/README.md b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/content-capture/README.md
new file mode 100644
index 0000000..5ab7ae8
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/content-capture/README.md
@@ -0,0 +1,42 @@
+# OpenAI Agents Content Capture Demo
+
+This example exercises the `OpenAIAgentsInstrumentor` with message content
+capture enabled, illustrating how prompts, responses, and tool payloads are
+recorded on spans and span events.
+
+> The demo uses the local tracing utilities from the `openai-agents`
+> package—no outbound API calls are made.
+
+## Prerequisites
+
+1. Activate the repository virtual environment:
+
+ ```bash
+ source ../../.venv/bin/activate
+ ```
+
+2. Copy `.env.example` to `.env` and provide any overrides you need (for example,
+ setting `OTEL_EXPORTER_OTLP_ENDPOINT`).
+3. Ensure `openai-agents` is installed in the environment (it is included in
+ the shared development venv for this repository).
+
+## Run the demo
+
+```bash
+python main.py
+```
+
+The script will:
+
+- Configure the OpenTelemetry SDK with an OTLP exporter so spans reach your collector.
+- Instrument the OpenAI Agents tracing hooks with content capture enabled.
+- Simulate an agent invocation that performs a generation and a tool call.
+- Print the resulting spans, attributes, and events (including JSON-encoded
+ prompts and responses) to stdout.
+
+## Customisation tips
+
+- Set `OTEL_SERVICE_NAME` before running to override the default service name.
+- Adjust the OTLP exporter configuration (endpoint, protocol) through `.env`.
+- Modify the prompts, tool payloads, or add additional spans in `run_workflow`
+ to explore different content capture scenarios.
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/content-capture/main.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/content-capture/main.py
new file mode 100644
index 0000000..23afcb5
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/content-capture/main.py
@@ -0,0 +1,122 @@
+"""
+Content capture demo for the OpenAI Agents instrumentation.
+
+This script spins up the instrumentation with message capture enabled and
+simulates an agent invocation plus a tool call using the tracing helpers from
+the ``openai-agents`` package. Spans are exported to the console so you can
+inspect captured prompts, responses, and tool payloads without making any
+OpenAI API calls.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+from typing import Any
+
+from agents.tracing import agent_span, function_span, generation_span, trace
+from dotenv import load_dotenv
+
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
+ OTLPSpanExporter,
+)
+from opentelemetry.instrumentation.openai_agents import (
+ OpenAIAgentsInstrumentor,
+)
+from opentelemetry.sdk.resources import Resource
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+
+load_dotenv() # take environment variables from .env.
+
+
+def configure_tracing() -> None:
+ """Configure a tracer provider that exports spans via OTLP."""
+ resource = Resource.create(
+ {
+ "service.name": os.environ.get(
+ "OTEL_SERVICE_NAME", "openai-agents-content-capture-demo"
+ )
+ }
+ )
+ provider = TracerProvider(resource=resource)
+ provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
+
+ # Instrument with explicit content capture mode to ensure prompts/responses are recorded.
+ OpenAIAgentsInstrumentor().instrument(
+ tracer_provider=provider,
+ capture_message_content="span_and_event",
+ system="openai",
+ agent_name="Travel Concierge",
+ base_url="https://api.openai.com/v1",
+ )
+
+
+def dump(title: str, payload: Any) -> None:
+ """Pretty-print helper used to show intermediate context."""
+ print(f"\n=== {title} ===")
+ print(json.dumps(payload, indent=2))
+
+
+def run_workflow() -> None:
+ """Simulate an agent workflow with a generation and a tool invocation."""
+ itinerary_prompt = [
+ {"role": "system", "content": "Plan high level travel itineraries."},
+ {
+ "role": "user",
+ "content": "I'm visiting Paris for 3 days in November.",
+ },
+ ]
+
+ tool_args = {"city": "Paris", "date": "2025-11-12"}
+ tool_result = {
+ "forecast": "Mostly sunny, highs 15°C",
+ "packing_tips": ["light jacket", "comfortable shoes"],
+ }
+
+ with trace("travel-booking-workflow"):
+ with agent_span(name="travel_planner") as agent:
+ dump(
+ "Agent span started",
+ {"span_id": agent.span_id, "trace_id": agent.trace_id},
+ )
+
+ with generation_span(
+ input=itinerary_prompt,
+ output=[
+ {
+ "role": "assistant",
+ "content": (
+ "Day 1 visit the Louvre, Day 2 tour Versailles, "
+ "Day 3 explore Montmartre."
+ ),
+ }
+ ],
+ model="gpt-4o-mini",
+ usage={
+ "input_tokens": 128,
+ "output_tokens": 96,
+ "total_tokens": 224,
+ },
+ ):
+ pass
+
+ with function_span(
+ name="fetch_weather",
+ input=json.dumps(tool_args),
+ output=tool_result,
+ ):
+ pass
+
+ print(
+ "\nWorkflow complete – spans exported to the configured OTLP endpoint."
+ )
+
+
+def main() -> None:
+ configure_tracing()
+ run_workflow()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/.env.example b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/.env.example
new file mode 100644
index 0000000..5a6d877
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/.env.example
@@ -0,0 +1,5 @@
+# Copy to .env and add real values before running main.py
+OPENAI_API_KEY=
+OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
+OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+OTEL_SERVICE_NAME=openai-agents-manual-demo
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/README.rst
new file mode 100644
index 0000000..18abec4
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/README.rst
@@ -0,0 +1,44 @@
+OpenTelemetry OpenAI Agents Instrumentation Example
+===================================================
+
+This example demonstrates how to manually configure the OpenTelemetry SDK
+alongside the OpenAI Agents instrumentation.
+
+Running `main.py `_ produces spans for the end-to-end agent run,
+including tool invocations and model generations. Spans are exported through
+OTLP/gRPC to the endpoint configured in the environment.
+
+Setup
+-----
+
+1. Copy `.env.example <.env.example>`_ to `.env` and update it with your real
+ ``OPENAI_API_KEY``. If your
+ OTLP collector is not reachable via ``http://localhost:4317``, adjust the
+ endpoint variables as needed.
+2. Create a virtual environment and install the dependencies:
+
+ ::
+
+ python3 -m venv .venv
+ source .venv/bin/activate
+ pip install "python-dotenv[cli]"
+ uv pip install -r requirements.txt --prerelease=allow
+
+Run
+---
+
+Execute the sample with ``dotenv`` so the environment variables from ``.env``
+are applied:
+
+::
+
+ dotenv run -- python main.py
+
+Ensure ``OPENAI_API_KEY`` is present in your environment (or ``.env`` file); the OpenAI client raises ``OpenAIError`` if the key is missing.
+
+The script automatically loads environment variables from ``.env`` so running
+``python main.py`` directly also works if the shell already has the required
+values exported.
+
+You should see the agent response printed to the console while spans export to
+your configured observability backend.
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/main.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/main.py
new file mode 100644
index 0000000..b85f81b
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/main.py
@@ -0,0 +1,124 @@
+# pylint: skip-file
+"""Manual OpenAI Agents instrumentation example."""
+
+from __future__ import annotations
+
+# ruff: noqa: I001
+
+from typing import Any, cast
+
+from dotenv import load_dotenv
+from opentelemetry import _logs
+from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
+ OTLPLogExporter,
+)
+from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
+ OTLPMetricExporter,
+)
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
+ OTLPSpanExporter,
+)
+from opentelemetry.instrumentation.openai_agents import (
+ OpenAIAgentsInstrumentor,
+)
+from opentelemetry.metrics import set_meter_provider
+from opentelemetry.sdk._logs import LoggerProvider
+from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
+from opentelemetry.sdk.metrics import MeterProvider
+from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+from opentelemetry.trace import get_tracer_provider, set_tracer_provider
+
+from agents import Agent, Runner, function_tool
+
+
+def _configure_manual_instrumentation() -> None:
+ """Configure tracing/metrics/logging manually so exported data goes to OTLP."""
+
+ # Traces
+ set_tracer_provider(TracerProvider())
+ tracer_provider = cast(Any, get_tracer_provider())
+ tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
+
+ # Metrics
+ metric_reader = PeriodicExportingMetricReader(OTLPMetricExporter())
+ set_meter_provider(MeterProvider(metric_readers=[metric_reader]))
+
+ # Logs
+ _logs.set_logger_provider(LoggerProvider())
+ logger_provider = cast(Any, _logs.get_logger_provider())
+ logger_provider.add_log_record_processor(
+ BatchLogRecordProcessor(OTLPLogExporter())
+ )
+
+ # OpenAI Agents instrumentation
+ instrumentor: Any = OpenAIAgentsInstrumentor()
+ instrumentor.instrument(tracer_provider=get_tracer_provider())
+
+
+@function_tool
+def get_weather(city: str) -> str:
+ """Return a canned weather response for the requested city."""
+
+ return f"The forecast for {city} is sunny with pleasant temperatures."
+
+
+def run_agent() -> None:
+ """Create a simple agent and execute a single run."""
+
+ assistant = Agent(
+ name="Travel Concierge",
+ instructions=(
+ "You are a concise travel concierge. Use the weather tool when the"
+ " traveler asks about local conditions."
+ ),
+ tools=[get_weather],
+ )
+
+ result = Runner.run_sync(
+ assistant,
+ "I'm visiting Barcelona this weekend. How should I pack?",
+ )
+
+ print("Agent response:")
+ print(result.final_output)
+
+
+@function_tool
+def get_budget(city: str) -> str:
+ """Return a simplified travel budget response for the requested city."""
+
+ return f"The plan for {city} is budget-friendly."
+
+
+def run_agent1() -> None:
+ """Create a simple agent and execute a single run."""
+
+ assistant = Agent(
+ name="Travel Budget",
+ instructions=(
+ "You are a concise travel budget planner. Use the budget tool when the"
+ " traveler asks about local conditions."
+ ),
+ tools=[get_budget],
+ )
+
+ result = Runner.run_sync(
+ assistant,
+ "I'm visiting Barcelona this weekend. How to plan my budget?",
+ )
+
+ print("Agent response:")
+ print(result.final_output)
+
+
+def main() -> None:
+ load_dotenv()
+ _configure_manual_instrumentation()
+ run_agent()
+ # run_agent1()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/requirements.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/requirements.txt
new file mode 100644
index 0000000..dccdfef
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/manual/requirements.txt
@@ -0,0 +1,6 @@
+openai-agents~=0.3.3
+python-dotenv~=1.0
+
+opentelemetry-sdk~=1.38.0.dev0
+opentelemetry-exporter-otlp-proto-grpc~=1.38.0.dev0
+opentelemetry-instrumentation-openai-agents-v2~=0.1.0.dev
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/travel-planner/main.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/travel-planner/main.py
new file mode 100644
index 0000000..38a3aad
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/travel-planner/main.py
@@ -0,0 +1,351 @@
+# 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.
+
+"""
+Multi-agent travel planner demonstrating OpenAI Agents v2 instrumentation.
+
+Uses the native OpenAI Agents SDK with multiple specialized agents to build
+a travel itinerary, demonstrating OpenTelemetry instrumentation with GenAI
+semantic conventions.
+
+Agents:
+- Flight Specialist: Searches for flights
+- Hotel Specialist: Recommends accommodations
+- Activity Specialist: Curates activities
+- Travel Coordinator: Orchestrates and synthesizes the plan
+
+See README.md for more information
+"""
+
+# Load environment variables FIRST before any other imports
+# This ensures OTEL_SERVICE_NAME and other env vars are available when SDK initializes
+from dotenv import load_dotenv
+
+load_dotenv()
+
+import argparse # noqa: E402
+import random # noqa: E402
+import time # noqa: E402
+from datetime import datetime, timedelta # noqa: E402
+
+from agents import Agent, Runner, function_tool # noqa: E402
+
+from opentelemetry import _events, _logs, metrics, trace # noqa: E402
+from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( # noqa: E402
+ OTLPLogExporter,
+)
+from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import ( # noqa: E402
+ OTLPMetricExporter,
+)
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # noqa: E402
+ OTLPSpanExporter,
+)
+from opentelemetry.instrumentation.openai_agents import ( # noqa: E402
+ OpenAIAgentsInstrumentor,
+)
+from opentelemetry.instrumentation.openai_agents.span_processor import ( # noqa: E402
+ start_multi_agent_workflow,
+ stop_multi_agent_workflow,
+)
+from opentelemetry.sdk._events import EventLoggerProvider # noqa: E402
+from opentelemetry.sdk._logs import LoggerProvider # noqa: E402
+from opentelemetry.sdk._logs.export import ( # noqa: E402
+ BatchLogRecordProcessor,
+)
+from opentelemetry.sdk.metrics import MeterProvider # noqa: E402
+from opentelemetry.sdk.metrics.export import ( # noqa: E402
+ PeriodicExportingMetricReader,
+)
+from opentelemetry.sdk.resources import Resource # noqa: E402
+from opentelemetry.sdk.trace import TracerProvider # noqa: E402
+from opentelemetry.sdk.trace.export import BatchSpanProcessor # noqa: E402
+
+# ---------------------------------------------------------------------------
+# Sample data
+# ---------------------------------------------------------------------------
+
+DESTINATIONS = {
+ "paris": {
+ "highlights": [
+ "Eiffel Tower at sunset",
+ "Seine dinner cruise",
+ "Day trip to Versailles",
+ ],
+ },
+ "tokyo": {
+ "highlights": [
+ "Tsukiji market food tour",
+ "Ghibli Museum visit",
+ "Day trip to Hakone hot springs",
+ ],
+ },
+ "rome": {
+ "highlights": [
+ "Colosseum underground tour",
+ "Private pasta masterclass",
+ "Sunset walk through Trastevere",
+ ],
+ },
+}
+
+
+# ---------------------------------------------------------------------------
+# Tool functions
+# ---------------------------------------------------------------------------
+
+
+@function_tool
+def search_flights(origin: str, destination: str, departure_date: str) -> str:
+ """Search for flight options between origin and destination."""
+ random.seed(hash((origin, destination, departure_date)) % (2**32))
+ airline = random.choice(["SkyLine", "AeroJet", "CloudNine"])
+ fare = random.randint(700, 1250)
+ return (
+ f"Top choice: {airline} non-stop service {origin}->{destination}, "
+ f"depart {departure_date} 09:15, arrive same day 17:05. "
+ f"Premium economy fare ${fare} return."
+ )
+
+
+@function_tool
+def search_hotels(destination: str, check_in: str, check_out: str) -> str:
+ """Search for hotel recommendations at the destination."""
+ random.seed(hash((destination, check_in, check_out)) % (2**32))
+ name = random.choice(["Grand Meridian", "Hotel Lumière", "The Atlas"])
+ rate = random.randint(240, 410)
+ return (
+ f"{name} near the historic centre. Boutique suites, rooftop bar, "
+ f"average nightly rate ${rate} including breakfast."
+ )
+
+
+@function_tool
+def search_activities(destination: str) -> str:
+ """Get signature activities and experiences for a destination."""
+ data = DESTINATIONS.get(destination.lower(), DESTINATIONS["paris"])
+ bullets = "\n".join(f"- {item}" for item in data["highlights"])
+ return f"Signature experiences in {destination.title()}:\n{bullets}"
+
+
+# ---------------------------------------------------------------------------
+# OpenTelemetry configuration
+# ---------------------------------------------------------------------------
+
+
+def configure_otel() -> None:
+ """Configure OpenTelemetry SDK for traces, metrics, and logs."""
+ # Create resource with service name from environment (OTEL_SERVICE_NAME)
+ # Resource.create() automatically picks up OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES
+ resource = Resource.create()
+
+ # Traces
+ trace_provider = TracerProvider(resource=resource)
+ trace_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
+ trace.set_tracer_provider(trace_provider)
+
+ # Metrics
+ metric_reader = PeriodicExportingMetricReader(OTLPMetricExporter())
+ metrics.set_meter_provider(MeterProvider(metric_readers=[metric_reader]))
+
+ # Logs
+ _logs.set_logger_provider(LoggerProvider())
+ _logs.get_logger_provider().add_log_record_processor(
+ BatchLogRecordProcessor(OTLPLogExporter())
+ )
+
+ # Events
+ _events.set_event_logger_provider(EventLoggerProvider())
+
+ # OpenAI Agents instrumentation
+ OpenAIAgentsInstrumentor().instrument(tracer_provider=trace_provider)
+
+
+# ---------------------------------------------------------------------------
+# Agent creation
+# ---------------------------------------------------------------------------
+
+
+def create_flight_agent() -> Agent:
+ """Create the flight specialist agent."""
+ return Agent(
+ name="Flight Specialist",
+ instructions=(
+ "You are a flight specialist. Search for the best flight options "
+ "using the search_flights tool. Provide clear recommendations including "
+ "airline, schedule, and fare information."
+ ),
+ tools=[search_flights],
+ )
+
+
+def create_hotel_agent() -> Agent:
+ """Create the hotel specialist agent."""
+ return Agent(
+ name="Hotel Specialist",
+ instructions=(
+ "You are a hotel specialist. Find the best accommodation using the "
+ "search_hotels tool. Provide detailed recommendations including "
+ "location, amenities, and pricing."
+ ),
+ tools=[search_hotels],
+ )
+
+
+def create_activity_agent() -> Agent:
+ """Create the activity specialist agent."""
+ return Agent(
+ name="Activity Specialist",
+ instructions=(
+ "You are an activities specialist. Curate memorable experiences using "
+ "the search_activities tool. Provide detailed activity recommendations "
+ "that match the traveler's interests."
+ ),
+ tools=[search_activities],
+ )
+
+
+def create_coordinator_agent() -> Agent:
+ """Create the travel coordinator agent that synthesizes the final itinerary."""
+ return Agent(
+ name="Travel Coordinator",
+ instructions=(
+ "You are a travel coordinator. Synthesize flight, hotel, and activity information "
+ "into a comprehensive, well-organized travel itinerary with clear sections."
+ ),
+ )
+
+
+# ---------------------------------------------------------------------------
+# Main workflow
+# ---------------------------------------------------------------------------
+
+
+def run_travel_planner() -> None:
+ """Execute the multi-agent travel planning workflow."""
+ # Sample travel request
+ origin = "Seattle"
+ destination = "Paris"
+ departure = (datetime.now() + timedelta(days=30)).strftime("%Y-%m-%d")
+ return_date = (datetime.now() + timedelta(days=37)).strftime("%Y-%m-%d")
+
+ print("🌍 Multi-Agent Travel Planner")
+ print("=" * 60)
+ print(f"\nOrigin: {origin}")
+ print(f"Destination: {destination}")
+ print(f"Dates: {departure} to {return_date}\n")
+ print("=" * 60)
+
+ # Create all specialist agents and coordinator
+ flight_agent = create_flight_agent()
+ hotel_agent = create_hotel_agent()
+ activity_agent = create_activity_agent()
+ coordinator = create_coordinator_agent()
+
+ # Start a global workflow that spans all agent calls
+ initial_request = f"Plan a romantic week-long trip from {origin} to {destination}, departing {departure} and returning {return_date}"
+ start_multi_agent_workflow(
+ workflow_name="travel-planner",
+ initial_input=initial_request,
+ )
+
+ final_output = None
+ try:
+ # Step 1: Flight Specialist
+ print("\n✈️ Flight Specialist - Searching for flights...")
+ flight_result = Runner.run_sync(
+ flight_agent,
+ f"Find flights from {origin} to {destination} departing {departure}",
+ )
+ flight_info = flight_result.final_output
+ print(f"Result: {flight_info[:200]}...\n")
+
+ # Step 2: Hotel Specialist
+ print("🏨 Hotel Specialist - Searching for hotels...")
+ hotel_result = Runner.run_sync(
+ hotel_agent,
+ f"Find a boutique hotel in {destination}, check-in {departure}, check-out {return_date}",
+ )
+ hotel_info = hotel_result.final_output
+ print(f"Result: {hotel_info[:200]}...\n")
+
+ # Step 3: Activity Specialist
+ print("🎭 Activity Specialist - Curating activities...")
+ activity_result = Runner.run_sync(
+ activity_agent,
+ f"Find unique activities and experiences in {destination}",
+ )
+ activity_info = activity_result.final_output
+ print(f"Result: {activity_info[:200]}...\n")
+
+ # Step 4: Coordinator - Synthesize final itinerary
+ print("📝 Coordinator - Creating final itinerary...")
+ synthesis_prompt = f"""
+Create a comprehensive travel itinerary with the following information:
+
+FLIGHTS:
+{flight_info}
+
+ACCOMMODATION:
+{hotel_info}
+
+ACTIVITIES:
+{activity_info}
+
+Please organize this into a clear, well-formatted itinerary for a romantic week-long trip.
+"""
+
+ final_result = Runner.run_sync(coordinator, synthesis_prompt)
+ final_output = final_result.final_output
+
+ print("\n" + "=" * 60)
+ print("✅ Travel Itinerary Complete!")
+ print("=" * 60)
+ print(f"\n{final_output}\n")
+
+ finally:
+ # Stop the global workflow with final output
+ stop_multi_agent_workflow(final_output=final_output)
+
+ # Allow time for telemetry to flush
+ time.sleep(2)
+
+
+# ---------------------------------------------------------------------------
+# Entry point
+# ---------------------------------------------------------------------------
+
+
+def main(manual_instrumentation: bool = False) -> None:
+ """Main entry point for the travel planner example."""
+ # Note: load_dotenv() is called at module level before imports
+
+ if manual_instrumentation:
+ configure_otel()
+ print("✓ Manual OpenTelemetry instrumentation configured")
+
+ run_travel_planner()
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Multi-agent travel planner example"
+ )
+ parser.add_argument(
+ "--manual-instrumentation",
+ action="store_true",
+ help="Use manual instrumentation (for debugging)",
+ )
+ args = parser.parse_args()
+
+ main(manual_instrumentation=args.manual_instrumentation)
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/travel-planner/requirements.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/travel-planner/requirements.txt
new file mode 100644
index 0000000..c60cf01
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/travel-planner/requirements.txt
@@ -0,0 +1,13 @@
+# OpenAI Agents SDK
+openai-agents~=0.3.3
+
+# Environment and utilities
+python-dotenv~=1.0.0
+
+# OpenTelemetry core and exporters
+opentelemetry-sdk~=1.38.0
+opentelemetry-exporter-otlp-proto-grpc~=1.38.0
+
+# OpenAI Agents v2 instrumentation (install from local editable)
+# Use: pip install -e ../../
+# opentelemetry-instrumentation-openai-agents-v2~=0.1.0.dev
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/.env.example b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/.env.example
new file mode 100644
index 0000000..8f39668
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/.env.example
@@ -0,0 +1,14 @@
+# Update this with your real OpenAI API key
+OPENAI_API_KEY=sk-YOUR_API_KEY
+
+# Uncomment and adjust if you use a non-default OTLP collector endpoint
+# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
+# OTEL_EXPORTER_OTLP_PROTOCOL=grpc
+
+OTEL_SERVICE_NAME=opentelemetry-python-openai-agents-zero-code
+
+# Enable auto-instrumentation for logs if desired
+OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true
+
+# Optionally override the agent name reported on spans
+# OTEL_GENAI_AGENT_NAME=Travel Concierge
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/README.rst
new file mode 100644
index 0000000..e2a76b4
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/README.rst
@@ -0,0 +1,43 @@
+OpenTelemetry OpenAI Agents Zero-Code Instrumentation Example
+=============================================================
+
+This example shows how to capture telemetry from OpenAI Agents without
+changing your application code by using ``opentelemetry-instrument``.
+
+When `main.py `_ is executed, spans describing the agent workflow are
+exported to the configured OTLP endpoint. The spans include details such as the
+operation name, tool usage, and token consumption (when available).
+
+Setup
+-----
+
+1. Copy `.env.example <.env.example>`_ to `.env` and update it with your real
+ ``OPENAI_API_KEY``. Adjust the
+ OTLP endpoint settings if your collector is not reachable via
+ ``http://localhost:4317``.
+2. Create a virtual environment and install the dependencies:
+
+ ::
+
+ python3 -m venv .venv
+ source .venv/bin/activate
+ pip install "python-dotenv[cli]"
+ uv pip install -r requirements.txt --prerelease=allow
+
+Run
+---
+
+Execute the sample via ``opentelemetry-instrument`` so the OpenAI Agents
+instrumentation is activated automatically:
+
+::
+
+ dotenv run -- opentelemetry-instrument python main.py
+
+Ensure ``OPENAI_API_KEY`` is set in your shell or `.env`; the OpenAI client raises ``OpenAIError`` if the key is missing.
+
+Because ``main.py`` invokes ``load_dotenv``, running ``python main.py`` directly
+also works when the required environment variables are already exported.
+
+You should see the agent response printed to the console while spans export to
+your observability backend.
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/main.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/main.py
new file mode 100644
index 0000000..4f59c01
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/main.py
@@ -0,0 +1,66 @@
+"""Zero-code OpenAI Agents example."""
+
+from __future__ import annotations
+
+from agents import Agent, Runner, function_tool
+from dotenv import load_dotenv
+
+from opentelemetry import trace
+from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
+ OTLPSpanExporter,
+)
+from opentelemetry.instrumentation.openai_agents import (
+ OpenAIAgentsInstrumentor,
+)
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import BatchSpanProcessor
+
+
+def configure_tracing() -> None:
+ """Ensure tracing exports spans even without auto-instrumentation."""
+
+ current_provider = trace.get_tracer_provider()
+ if isinstance(current_provider, TracerProvider):
+ provider = current_provider
+ else:
+ provider = TracerProvider()
+ provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
+ trace.set_tracer_provider(provider)
+
+ OpenAIAgentsInstrumentor().instrument(tracer_provider=provider)
+
+
+@function_tool
+def get_weather(city: str) -> str:
+ """Return a canned weather response for the requested city."""
+
+ return f"The forecast for {city} is sunny with pleasant temperatures."
+
+
+def run_agent() -> None:
+ assistant = Agent(
+ name="Travel Concierge",
+ instructions=(
+ "You are a concise travel concierge. Use the weather tool when the"
+ " traveler asks about local conditions."
+ ),
+ tools=[get_weather],
+ )
+
+ result = Runner.run_sync(
+ assistant,
+ "I'm visiting Barcelona this weekend. How should I pack?",
+ )
+
+ print("Agent response:")
+ print(result.final_output)
+
+
+def main() -> None:
+ load_dotenv()
+ configure_tracing()
+ run_agent()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/requirements.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/requirements.txt
new file mode 100644
index 0000000..2b3a8ca
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/examples/zero-code/requirements.txt
@@ -0,0 +1,7 @@
+openai-agents~=0.3.3
+python-dotenv~=1.0
+
+opentelemetry-sdk~=1.36.0
+opentelemetry-exporter-otlp-proto-grpc~=1.36.0
+opentelemetry-distro~=0.57b0
+opentelemetry-instrumentation-openai-agents-v2~=0.1.0.dev
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/pyproject.toml
new file mode 100644
index 0000000..13dc286
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/pyproject.toml
@@ -0,0 +1,55 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "opentelemetry-instrumentation-openai-agents-v2"
+dynamic = ["version"]
+description = "OpenTelemetry Official OpenAI Agents instrumentation"
+readme = "README.rst"
+license = "Apache-2.0"
+requires-python = ">=3.9"
+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.9",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+]
+dependencies = [
+ "opentelemetry-api >= 1.37",
+ "opentelemetry-instrumentation >= 0.58b0",
+ "opentelemetry-semantic-conventions >= 0.58b0",
+ "opentelemetry-util-genai"
+]
+
+[project.optional-dependencies]
+instruments = [
+ "openai-agents >= 0.3.3",
+]
+
+[project.entry-points.opentelemetry_instrumentor]
+openai_agents = "opentelemetry.instrumentation.openai_agents:OpenAIAgentsInstrumentor"
+
+[project.urls]
+Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2"
+Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib"
+
+[tool.hatch.version]
+path = "src/opentelemetry/instrumentation/openai_agents/version.py"
+
+[tool.hatch.build.targets.sdist]
+include = [
+ "/src",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/opentelemetry"]
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/__init__.py
new file mode 100644
index 0000000..45d11bf
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/__init__.py
@@ -0,0 +1,211 @@
+"""OpenAI Agents instrumentation for OpenTelemetry."""
+
+# pylint: disable=too-many-locals
+
+from __future__ import annotations
+
+import importlib
+import logging
+import os
+from typing import Any, Collection
+
+from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
+from opentelemetry.semconv._incubating.attributes import (
+ gen_ai_attributes as GenAI,
+)
+from opentelemetry.semconv.schemas import Schemas
+from opentelemetry.trace import get_tracer
+
+from .package import _instruments
+from .span_processor import (
+ ContentCaptureMode,
+ GenAIEvaluationAttributes,
+ GenAIOperationName,
+ GenAIOutputType,
+ GenAIProvider,
+ GenAISemanticProcessor,
+ GenAIToolType,
+)
+
+__all__ = [
+ "OpenAIAgentsInstrumentor",
+ "GenAIProvider",
+ "GenAIOperationName",
+ "GenAIToolType",
+ "GenAIOutputType",
+ "GenAIEvaluationAttributes",
+]
+
+logger = logging.getLogger(__name__)
+
+_CONTENT_CAPTURE_ENV = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"
+_SYSTEM_OVERRIDE_ENV = "OTEL_INSTRUMENTATION_OPENAI_AGENTS_SYSTEM"
+_CAPTURE_CONTENT_ENV = "OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_CONTENT"
+_CAPTURE_METRICS_ENV = "OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_METRICS"
+
+
+def _load_tracing_module(): # pragma: no cover - exercised via tests
+ return importlib.import_module("agents.tracing")
+
+
+def _get_registered_processors(provider) -> list:
+ multi = getattr(provider, "_multi_processor", None)
+ processors = getattr(multi, "_processors", ())
+ return list(processors)
+
+
+def _resolve_system(value: str | None) -> str:
+ if not value:
+ return GenAI.GenAiSystemValues.OPENAI.value
+
+ normalized = value.strip().lower()
+ for member in GenAI.GenAiSystemValues:
+ if normalized == member.value:
+ return member.value
+ if normalized == member.name.lower():
+ return member.value
+ return value
+
+
+def _resolve_content_mode(value: Any) -> ContentCaptureMode:
+ if isinstance(value, ContentCaptureMode):
+ return value
+ if isinstance(value, bool):
+ return (
+ ContentCaptureMode.SPAN_AND_EVENT
+ if value
+ else ContentCaptureMode.NO_CONTENT
+ )
+
+ if value is None:
+ return ContentCaptureMode.SPAN_AND_EVENT
+
+ text = str(value).strip().lower()
+ if not text:
+ return ContentCaptureMode.SPAN_AND_EVENT
+
+ mapping = {
+ "span_only": ContentCaptureMode.SPAN_ONLY,
+ "span-only": ContentCaptureMode.SPAN_ONLY,
+ "span": ContentCaptureMode.SPAN_ONLY,
+ "event_only": ContentCaptureMode.EVENT_ONLY,
+ "event-only": ContentCaptureMode.EVENT_ONLY,
+ "event": ContentCaptureMode.EVENT_ONLY,
+ "span_and_event": ContentCaptureMode.SPAN_AND_EVENT,
+ "span-and-event": ContentCaptureMode.SPAN_AND_EVENT,
+ "span_and_events": ContentCaptureMode.SPAN_AND_EVENT,
+ "all": ContentCaptureMode.SPAN_AND_EVENT,
+ "true": ContentCaptureMode.SPAN_AND_EVENT,
+ "1": ContentCaptureMode.SPAN_AND_EVENT,
+ "yes": ContentCaptureMode.SPAN_AND_EVENT,
+ "no_content": ContentCaptureMode.NO_CONTENT,
+ "false": ContentCaptureMode.NO_CONTENT,
+ "0": ContentCaptureMode.NO_CONTENT,
+ "no": ContentCaptureMode.NO_CONTENT,
+ "none": ContentCaptureMode.NO_CONTENT,
+ }
+
+ return mapping.get(text, ContentCaptureMode.SPAN_AND_EVENT)
+
+
+def _resolve_bool(value: Any, default: bool) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ text = str(value).strip().lower()
+ if text in {"true", "1", "yes", "on"}:
+ return True
+ if text in {"false", "0", "no", "off"}:
+ return False
+ return default
+
+
+class OpenAIAgentsInstrumentor(BaseInstrumentor):
+ """Instrumentation that bridges OpenAI Agents tracing to OpenTelemetry."""
+
+ def __init__(self) -> None:
+ super().__init__()
+ self._processor: GenAISemanticProcessor | None = None
+
+ def _instrument(self, **kwargs) -> None:
+ if self._processor is not None:
+ return
+
+ tracer_provider = kwargs.get("tracer_provider")
+ tracer = get_tracer(
+ __name__,
+ "",
+ tracer_provider,
+ schema_url=Schemas.V1_28_0.value,
+ )
+
+ system_override = kwargs.get("system") or os.getenv(
+ _SYSTEM_OVERRIDE_ENV
+ )
+ system = _resolve_system(system_override)
+
+ content_override = kwargs.get("capture_message_content")
+ if content_override is None:
+ content_override = os.getenv(_CONTENT_CAPTURE_ENV) or os.getenv(
+ _CAPTURE_CONTENT_ENV
+ )
+ content_mode = _resolve_content_mode(content_override)
+
+ metrics_override = kwargs.get("capture_metrics")
+ if metrics_override is None:
+ metrics_override = os.getenv(_CAPTURE_METRICS_ENV)
+ metrics_enabled = _resolve_bool(metrics_override, default=True)
+
+ agent_name = kwargs.get("agent_name")
+ agent_id = kwargs.get("agent_id")
+ agent_description = kwargs.get("agent_description")
+ base_url = kwargs.get("base_url")
+ server_address = kwargs.get("server_address")
+ server_port = kwargs.get("server_port")
+
+ processor = GenAISemanticProcessor(
+ tracer=tracer,
+ system_name=system,
+ include_sensitive_data=content_mode
+ != ContentCaptureMode.NO_CONTENT,
+ content_mode=content_mode,
+ metrics_enabled=metrics_enabled,
+ agent_name=agent_name,
+ agent_id=agent_id,
+ agent_description=agent_description,
+ base_url=base_url,
+ server_address=server_address,
+ server_port=server_port,
+ agent_name_default="OpenAI Agent",
+ agent_id_default="agent",
+ agent_description_default="OpenAI Agents instrumentation",
+ base_url_default="https://api.openai.com",
+ server_address_default="api.openai.com",
+ server_port_default=443,
+ tracer_provider=tracer_provider,
+ )
+
+ tracing = _load_tracing_module()
+ provider = tracing.get_trace_provider()
+ existing = _get_registered_processors(provider)
+ provider.set_processors([*existing, processor])
+ self._processor = processor
+
+ def _uninstrument(self, **kwargs) -> None:
+ if self._processor is None:
+ return
+
+ tracing = _load_tracing_module()
+ provider = tracing.get_trace_provider()
+ current = _get_registered_processors(provider)
+ filtered = [proc for proc in current if proc is not self._processor]
+ provider.set_processors(filtered)
+
+ try:
+ self._processor.shutdown()
+ finally:
+ self._processor = None
+
+ def instrumentation_dependencies(self) -> Collection[str]:
+ return _instruments
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/package.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/package.py
new file mode 100644
index 0000000..18bd3f6
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/package.py
@@ -0,0 +1,16 @@
+# 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.
+
+_instruments = ("openai-agents >= 0.3.3",)
+_supports_metrics = False
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/span_processor.py
new file mode 100644
index 0000000..0a4a84a
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/span_processor.py
@@ -0,0 +1,2506 @@
+"""
+GenAI Semantic Convention Trace Processor
+
+This module implements a custom trace processor that enriches spans with
+OpenTelemetry GenAI semantic conventions attributes following the
+OpenInference processor pattern. It adds standardized attributes for
+generative AI operations using iterator-based attribute extraction.
+
+References:
+- OpenTelemetry GenAI Semantic Conventions:
+ https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-spans/
+- OpenInference Pattern: https://github.com/Arize-ai/openinference
+"""
+
+# pylint: disable=too-many-lines,invalid-name,too-many-locals,too-many-branches,too-many-statements,too-many-return-statements,too-many-nested-blocks,too-many-arguments,too-many-instance-attributes,broad-exception-caught,no-self-use,consider-iterating-dictionary,unused-variable,unnecessary-pass
+# ruff: noqa: I001
+
+from __future__ import annotations
+
+import importlib
+import logging
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from enum import Enum
+from typing import TYPE_CHECKING, Any, Dict, Iterator, Optional, Sequence
+from urllib.parse import urlparse
+
+from opentelemetry.util.genai.handler import (
+ TelemetryHandler,
+ get_telemetry_handler,
+)
+from opentelemetry.util.genai.types import (
+ AgentInvocation,
+ LLMInvocation,
+ Step,
+ ToolCall,
+ Workflow,
+)
+from opentelemetry.util.genai.utils import gen_ai_json_dumps
+
+try:
+ from agents.tracing import Span, Trace, TracingProcessor
+ from agents.tracing.span_data import (
+ AgentSpanData,
+ FunctionSpanData,
+ GenerationSpanData,
+ GuardrailSpanData,
+ HandoffSpanData,
+ ResponseSpanData,
+ SpeechSpanData,
+ TranscriptionSpanData,
+ )
+except ModuleNotFoundError: # pragma: no cover - test stubs
+ tracing_module = importlib.import_module("agents.tracing")
+ Span = getattr(tracing_module, "Span")
+ Trace = getattr(tracing_module, "Trace")
+ TracingProcessor = getattr(tracing_module, "TracingProcessor")
+ AgentSpanData = getattr(tracing_module, "AgentSpanData", Any) # type: ignore[assignment]
+ FunctionSpanData = getattr(tracing_module, "FunctionSpanData", Any) # type: ignore[assignment]
+ GenerationSpanData = getattr(tracing_module, "GenerationSpanData", Any) # type: ignore[assignment]
+ GuardrailSpanData = getattr(tracing_module, "GuardrailSpanData", Any) # type: ignore[assignment]
+ HandoffSpanData = getattr(tracing_module, "HandoffSpanData", Any) # type: ignore[assignment]
+ ResponseSpanData = getattr(tracing_module, "ResponseSpanData", Any) # type: ignore[assignment]
+ SpeechSpanData = getattr(tracing_module, "SpeechSpanData", Any) # type: ignore[assignment]
+ TranscriptionSpanData = getattr(
+ tracing_module, "TranscriptionSpanData", Any
+ ) # type: ignore[assignment]
+
+from opentelemetry.context import attach, detach
+from opentelemetry.metrics import Histogram, get_meter
+from opentelemetry.semconv._incubating.attributes import (
+ gen_ai_attributes as GenAIAttributes,
+)
+from opentelemetry.semconv._incubating.attributes import (
+ server_attributes as ServerAttributes,
+)
+from opentelemetry.trace import Span as OtelSpan
+from opentelemetry.trace import (
+ SpanKind,
+ Status,
+ StatusCode,
+ Tracer,
+ get_current_span,
+ set_span_in_context,
+)
+from opentelemetry.util.types import AttributeValue
+
+# ---------------------------------------------------------------------------
+# Global workflow context for multi-agent scenarios
+# ---------------------------------------------------------------------------
+
+_GLOBAL_WORKFLOW_CONTEXT: dict[str, Any] = {}
+_GLOBAL_PROCESSOR_REF: list = [] # Holds reference to active processor
+
+
+def start_multi_agent_workflow(
+ workflow_name: str,
+ initial_input: Optional[str] = None,
+ **kwargs: Any,
+) -> None:
+ """Start a global workflow context for multi-agent scenarios.
+
+ This allows multiple independent agent runs to be grouped under a single
+ workflow span. Call this before running multiple agents, and call
+ stop_multi_agent_workflow() when done.
+
+ Args:
+ workflow_name: Name of the workflow
+ initial_input: Optional initial input/request for the workflow
+ **kwargs: Additional attributes to store in the workflow context
+ """
+ _GLOBAL_WORKFLOW_CONTEXT["workflow_name"] = workflow_name
+ _GLOBAL_WORKFLOW_CONTEXT["initial_input"] = initial_input
+ _GLOBAL_WORKFLOW_CONTEXT["attributes"] = kwargs
+ _GLOBAL_WORKFLOW_CONTEXT["active"] = True
+
+ # Immediately start the workflow span via the processor if available
+ if _GLOBAL_PROCESSOR_REF:
+ processor = _GLOBAL_PROCESSOR_REF[0]
+ if processor._workflow is None:
+ wf_attrs = kwargs.copy() if kwargs else {}
+ workflow = Workflow(name=workflow_name, attributes=wf_attrs)
+ workflow.initial_input = initial_input
+ processor._workflow = workflow
+ processor._handler.start_workflow(workflow)
+
+
+def stop_multi_agent_workflow(final_output: Optional[str] = None) -> None:
+ """Stop the global workflow context and finalize the workflow span.
+
+ Args:
+ final_output: Optional final output of the workflow
+ """
+ # Stop the workflow via the processor if available
+ if _GLOBAL_PROCESSOR_REF:
+ processor = _GLOBAL_PROCESSOR_REF[0]
+ if processor._workflow is not None:
+ processor._workflow.final_output = final_output
+ processor._handler.stop_workflow(processor._workflow)
+ processor._workflow = None
+
+ _GLOBAL_WORKFLOW_CONTEXT["final_output"] = final_output
+ _GLOBAL_WORKFLOW_CONTEXT["active"] = False
+ _GLOBAL_WORKFLOW_CONTEXT.clear()
+
+
+# Import all semantic convention constants
+# ---- GenAI semantic convention helpers (embedded from constants.py) ----
+
+
+def _enum_values(enum_cls) -> dict[str, str]:
+ """Return mapping of enum member name to value."""
+ return {member.name: member.value for member in enum_cls}
+
+
+_PROVIDER_VALUES = _enum_values(GenAIAttributes.GenAiProviderNameValues)
+
+
+class GenAIProvider:
+ OPENAI = _PROVIDER_VALUES["OPENAI"]
+ GCP_GEN_AI = _PROVIDER_VALUES["GCP_GEN_AI"]
+ GCP_VERTEX_AI = _PROVIDER_VALUES["GCP_VERTEX_AI"]
+ GCP_GEMINI = _PROVIDER_VALUES["GCP_GEMINI"]
+ ANTHROPIC = _PROVIDER_VALUES["ANTHROPIC"]
+ COHERE = _PROVIDER_VALUES["COHERE"]
+ AZURE_AI_INFERENCE = _PROVIDER_VALUES["AZURE_AI_INFERENCE"]
+ AZURE_AI_OPENAI = _PROVIDER_VALUES["AZURE_AI_OPENAI"]
+ IBM_WATSONX_AI = _PROVIDER_VALUES["IBM_WATSONX_AI"]
+ AWS_BEDROCK = _PROVIDER_VALUES["AWS_BEDROCK"]
+ PERPLEXITY = _PROVIDER_VALUES["PERPLEXITY"]
+ X_AI = _PROVIDER_VALUES["X_AI"]
+ DEEPSEEK = _PROVIDER_VALUES["DEEPSEEK"]
+ GROQ = _PROVIDER_VALUES["GROQ"]
+ MISTRAL_AI = _PROVIDER_VALUES["MISTRAL_AI"]
+
+ ALL = set(_PROVIDER_VALUES.values())
+
+
+_OPERATION_VALUES = _enum_values(GenAIAttributes.GenAiOperationNameValues)
+
+
+class GenAIOperationName:
+ CHAT = _OPERATION_VALUES["CHAT"]
+ GENERATE_CONTENT = _OPERATION_VALUES["GENERATE_CONTENT"]
+ TEXT_COMPLETION = _OPERATION_VALUES["TEXT_COMPLETION"]
+ EMBEDDINGS = _OPERATION_VALUES["EMBEDDINGS"]
+ CREATE_AGENT = _OPERATION_VALUES["CREATE_AGENT"]
+ INVOKE_AGENT = _OPERATION_VALUES["INVOKE_AGENT"]
+ EXECUTE_TOOL = _OPERATION_VALUES["EXECUTE_TOOL"]
+ # Operations below are not yet covered by the spec but remain for backwards compatibility
+ TRANSCRIPTION = "transcription"
+ SPEECH = "speech_generation"
+ GUARDRAIL = "guardrail_check"
+ HANDOFF = "agent_handoff"
+ RESPONSE = "response" # internal aggregator in current processor
+
+ CLASS_FALLBACK = {
+ "generationspan": CHAT,
+ "responsespan": RESPONSE,
+ "functionspan": EXECUTE_TOOL,
+ "agentspan": INVOKE_AGENT,
+ }
+
+
+_OUTPUT_VALUES = _enum_values(GenAIAttributes.GenAiOutputTypeValues)
+
+
+class GenAIOutputType:
+ TEXT = _OUTPUT_VALUES["TEXT"]
+ JSON = _OUTPUT_VALUES["JSON"]
+ IMAGE = _OUTPUT_VALUES["IMAGE"]
+ SPEECH = _OUTPUT_VALUES["SPEECH"]
+
+
+class GenAIToolType:
+ FUNCTION = "function"
+ EXTENSION = "extension"
+ DATASTORE = "datastore"
+
+ ALL = {FUNCTION, EXTENSION, DATASTORE}
+
+
+class GenAIEvaluationAttributes:
+ NAME = "gen_ai.evaluation.name"
+ SCORE_VALUE = "gen_ai.evaluation.score.value"
+ SCORE_LABEL = "gen_ai.evaluation.score.label"
+ EXPLANATION = "gen_ai.evaluation.explanation"
+
+
+def _attr(name: str, fallback: str) -> str:
+ return getattr(GenAIAttributes, name, fallback)
+
+
+GEN_AI_PROVIDER_NAME = _attr("GEN_AI_PROVIDER_NAME", "gen_ai.provider.name")
+GEN_AI_OPERATION_NAME = _attr("GEN_AI_OPERATION_NAME", "gen_ai.operation.name")
+GEN_AI_REQUEST_MODEL = _attr("GEN_AI_REQUEST_MODEL", "gen_ai.request.model")
+GEN_AI_REQUEST_MAX_TOKENS = _attr(
+ "GEN_AI_REQUEST_MAX_TOKENS", "gen_ai.request.max_tokens"
+)
+GEN_AI_REQUEST_TEMPERATURE = _attr(
+ "GEN_AI_REQUEST_TEMPERATURE", "gen_ai.request.temperature"
+)
+GEN_AI_REQUEST_TOP_P = _attr("GEN_AI_REQUEST_TOP_P", "gen_ai.request.top_p")
+GEN_AI_REQUEST_TOP_K = _attr("GEN_AI_REQUEST_TOP_K", "gen_ai.request.top_k")
+GEN_AI_REQUEST_FREQUENCY_PENALTY = _attr(
+ "GEN_AI_REQUEST_FREQUENCY_PENALTY", "gen_ai.request.frequency_penalty"
+)
+GEN_AI_REQUEST_PRESENCE_PENALTY = _attr(
+ "GEN_AI_REQUEST_PRESENCE_PENALTY", "gen_ai.request.presence_penalty"
+)
+GEN_AI_REQUEST_CHOICE_COUNT = _attr(
+ "GEN_AI_REQUEST_CHOICE_COUNT", "gen_ai.request.choice.count"
+)
+GEN_AI_REQUEST_STOP_SEQUENCES = _attr(
+ "GEN_AI_REQUEST_STOP_SEQUENCES", "gen_ai.request.stop_sequences"
+)
+GEN_AI_REQUEST_ENCODING_FORMATS = _attr(
+ "GEN_AI_REQUEST_ENCODING_FORMATS", "gen_ai.request.encoding_formats"
+)
+GEN_AI_REQUEST_SEED = _attr("GEN_AI_REQUEST_SEED", "gen_ai.request.seed")
+GEN_AI_RESPONSE_ID = _attr("GEN_AI_RESPONSE_ID", "gen_ai.response.id")
+GEN_AI_RESPONSE_MODEL = _attr("GEN_AI_RESPONSE_MODEL", "gen_ai.response.model")
+GEN_AI_RESPONSE_FINISH_REASONS = _attr(
+ "GEN_AI_RESPONSE_FINISH_REASONS", "gen_ai.response.finish_reasons"
+)
+GEN_AI_USAGE_INPUT_TOKENS = _attr(
+ "GEN_AI_USAGE_INPUT_TOKENS", "gen_ai.usage.input_tokens"
+)
+GEN_AI_USAGE_OUTPUT_TOKENS = _attr(
+ "GEN_AI_USAGE_OUTPUT_TOKENS", "gen_ai.usage.output_tokens"
+)
+GEN_AI_CONVERSATION_ID = _attr(
+ "GEN_AI_CONVERSATION_ID", "gen_ai.conversation.id"
+)
+GEN_AI_AGENT_ID = _attr("GEN_AI_AGENT_ID", "gen_ai.agent.id")
+GEN_AI_AGENT_NAME = _attr("GEN_AI_AGENT_NAME", "gen_ai.agent.name")
+GEN_AI_AGENT_DESCRIPTION = _attr(
+ "GEN_AI_AGENT_DESCRIPTION", "gen_ai.agent.description"
+)
+GEN_AI_TOOL_NAME = _attr("GEN_AI_TOOL_NAME", "gen_ai.tool.name")
+GEN_AI_TOOL_TYPE = _attr("GEN_AI_TOOL_TYPE", "gen_ai.tool.type")
+GEN_AI_TOOL_CALL_ID = _attr("GEN_AI_TOOL_CALL_ID", "gen_ai.tool.call.id")
+GEN_AI_TOOL_DESCRIPTION = _attr(
+ "GEN_AI_TOOL_DESCRIPTION", "gen_ai.tool.description"
+)
+GEN_AI_OUTPUT_TYPE = _attr("GEN_AI_OUTPUT_TYPE", "gen_ai.output.type")
+GEN_AI_SYSTEM_INSTRUCTIONS = _attr(
+ "GEN_AI_SYSTEM_INSTRUCTIONS", "gen_ai.system_instructions"
+)
+GEN_AI_INPUT_MESSAGES = _attr("GEN_AI_INPUT_MESSAGES", "gen_ai.input.messages")
+GEN_AI_OUTPUT_MESSAGES = _attr(
+ "GEN_AI_OUTPUT_MESSAGES", "gen_ai.output.messages"
+)
+GEN_AI_DATA_SOURCE_ID = _attr("GEN_AI_DATA_SOURCE_ID", "gen_ai.data_source.id")
+
+# The semantic conventions currently expose multiple usage token attributes; we retain the
+# completion/prompt aliases for backwards compatibility where used.
+GEN_AI_USAGE_PROMPT_TOKENS = _attr(
+ "GEN_AI_USAGE_PROMPT_TOKENS", "gen_ai.usage.prompt_tokens"
+)
+GEN_AI_USAGE_COMPLETION_TOKENS = _attr(
+ "GEN_AI_USAGE_COMPLETION_TOKENS", "gen_ai.usage.completion_tokens"
+)
+
+# Attributes not (yet) defined in the spec retain their literal values.
+GEN_AI_TOOL_CALL_ARGUMENTS = "gen_ai.tool.call.arguments"
+GEN_AI_TOOL_CALL_RESULT = "gen_ai.tool.call.result"
+GEN_AI_TOOL_DEFINITIONS = "gen_ai.tool.definitions"
+GEN_AI_ORCHESTRATOR_AGENT_DEFINITIONS = "gen_ai.orchestrator.agent.definitions"
+GEN_AI_GUARDRAIL_NAME = "gen_ai.guardrail.name"
+GEN_AI_GUARDRAIL_TRIGGERED = "gen_ai.guardrail.triggered"
+GEN_AI_HANDOFF_FROM_AGENT = "gen_ai.handoff.from_agent"
+GEN_AI_HANDOFF_TO_AGENT = "gen_ai.handoff.to_agent"
+GEN_AI_EMBEDDINGS_DIMENSION_COUNT = "gen_ai.embeddings.dimension.count"
+GEN_AI_TOKEN_TYPE = _attr("GEN_AI_TOKEN_TYPE", "gen_ai.token.type")
+
+# ---- Normalization utilities (embedded from utils.py) ----
+
+
+def normalize_provider(provider: Optional[str]) -> Optional[str]:
+ """Normalize provider name to spec-compliant value."""
+ if not provider:
+ return None
+ normalized = provider.strip().lower()
+ if normalized in GenAIProvider.ALL:
+ return normalized
+ return provider # passthrough if unknown (forward compat)
+
+
+def validate_tool_type(tool_type: Optional[str]) -> str:
+ """Validate and normalize tool type."""
+ if not tool_type:
+ return GenAIToolType.FUNCTION # default
+ normalized = tool_type.strip().lower()
+ return (
+ normalized
+ if normalized in GenAIToolType.ALL
+ else GenAIToolType.FUNCTION
+ )
+
+
+def normalize_output_type(output_type: Optional[str]) -> str:
+ """Normalize output type to spec-compliant value."""
+ if not output_type:
+ return GenAIOutputType.TEXT # default
+ normalized = output_type.strip().lower()
+ base_map = {
+ "json_object": GenAIOutputType.JSON,
+ "jsonschema": GenAIOutputType.JSON,
+ "speech_audio": GenAIOutputType.SPEECH,
+ "audio_speech": GenAIOutputType.SPEECH,
+ "image_png": GenAIOutputType.IMAGE,
+ "function_arguments_json": GenAIOutputType.JSON,
+ "tool_call": GenAIOutputType.JSON,
+ "transcription_json": GenAIOutputType.JSON,
+ }
+ if normalized in base_map:
+ return base_map[normalized]
+ if normalized in {
+ GenAIOutputType.TEXT,
+ GenAIOutputType.JSON,
+ GenAIOutputType.IMAGE,
+ GenAIOutputType.SPEECH,
+ }:
+ return normalized
+ return GenAIOutputType.TEXT # default for unknown
+
+
+if TYPE_CHECKING:
+ pass
+
+# Legacy attributes removed
+
+logger = logging.getLogger(__name__)
+
+GEN_AI_SYSTEM_KEY = getattr(GenAIAttributes, "GEN_AI_SYSTEM", "gen_ai.system")
+
+
+class ContentCaptureMode(Enum):
+ """Controls whether sensitive content is recorded on spans, events, or both."""
+
+ NO_CONTENT = "no_content"
+ SPAN_ONLY = "span_only"
+ EVENT_ONLY = "event_only"
+ SPAN_AND_EVENT = "span_and_event"
+
+ @property
+ def capture_in_span(self) -> bool:
+ return self in (
+ ContentCaptureMode.SPAN_ONLY,
+ ContentCaptureMode.SPAN_AND_EVENT,
+ )
+
+ @property
+ def capture_in_event(self) -> bool:
+ return self in (
+ ContentCaptureMode.EVENT_ONLY,
+ ContentCaptureMode.SPAN_AND_EVENT,
+ )
+
+
+@dataclass
+class ContentPayload:
+ """Container for normalized content associated with a span."""
+
+ input_messages: Optional[list[dict[str, Any]]] = None
+ output_messages: Optional[list[dict[str, Any]]] = None
+ system_instructions: Optional[list[dict[str, str]]] = None
+ tool_arguments: Any = None
+ tool_result: Any = None
+
+
+def _is_instance_of(value: Any, classes: Any) -> bool:
+ """Safe isinstance that tolerates typing.Any placeholders."""
+ if not isinstance(classes, tuple):
+ classes = (classes,)
+ for cls in classes:
+ try:
+ if isinstance(value, cls):
+ return True
+ except TypeError:
+ continue
+ return False
+
+
+def _infer_server_attributes(base_url: Optional[str]) -> dict[str, Any]:
+ """Return server.address / server.port attributes if base_url provided."""
+ out: dict[str, Any] = {}
+ if not base_url:
+ return out
+ try:
+ parsed = urlparse(base_url)
+ if parsed.hostname:
+ out[ServerAttributes.SERVER_ADDRESS] = parsed.hostname
+ if parsed.port:
+ out[ServerAttributes.SERVER_PORT] = parsed.port
+ except Exception:
+ return out
+ return out
+
+
+def safe_json_dumps(obj: Any) -> str:
+ """Safely convert object to JSON string (fallback to str)."""
+ try:
+ return gen_ai_json_dumps(obj)
+ except (TypeError, ValueError):
+ return str(obj)
+
+
+def _as_utc_nano(dt: datetime) -> int:
+ """Convert datetime to UTC nanoseconds timestamp."""
+ return int(dt.astimezone(timezone.utc).timestamp() * 1_000_000_000)
+
+
+def _get_span_status(span: Span[Any]) -> Status:
+ """Get OpenTelemetry span status from agent span."""
+ if error := getattr(span, "error", None):
+ return Status(
+ status_code=StatusCode.ERROR,
+ description=f"{error.get('message', '')}: {error.get('data', '')}",
+ )
+ return Status(StatusCode.OK)
+
+
+def get_span_name(
+ operation_name: str,
+ model: Optional[str] = None,
+ agent_name: Optional[str] = None,
+ tool_name: Optional[str] = None,
+) -> str:
+ """Generate spec-compliant span name based on operation type."""
+ base_name = operation_name
+
+ if operation_name in {
+ GenAIOperationName.CHAT,
+ GenAIOperationName.TEXT_COMPLETION,
+ GenAIOperationName.EMBEDDINGS,
+ GenAIOperationName.TRANSCRIPTION,
+ GenAIOperationName.SPEECH,
+ }:
+ return f"{base_name} {model}" if model else base_name
+
+ if operation_name == GenAIOperationName.CREATE_AGENT:
+ return f"{base_name} {agent_name}" if agent_name else base_name
+
+ if operation_name == GenAIOperationName.INVOKE_AGENT:
+ return f"{base_name} {agent_name}" if agent_name else base_name
+
+ if operation_name == GenAIOperationName.EXECUTE_TOOL:
+ return f"{base_name} {tool_name}" if tool_name else base_name
+
+ if operation_name == GenAIOperationName.HANDOFF:
+ return f"{base_name} {agent_name}" if agent_name else base_name
+
+ return base_name
+
+
+class GenAISemanticProcessor(TracingProcessor):
+ """Trace processor adding GenAI semantic convention attributes with metrics."""
+
+ _handler: TelemetryHandler
+
+ def __init__(
+ self,
+ tracer: Optional[Tracer] = None,
+ system_name: str = "openai",
+ include_sensitive_data: bool = True,
+ content_mode: ContentCaptureMode = ContentCaptureMode.SPAN_AND_EVENT,
+ base_url: Optional[str] = None,
+ agent_name: Optional[str] = None,
+ agent_id: Optional[str] = None,
+ agent_description: Optional[str] = None,
+ server_address: Optional[str] = None,
+ server_port: Optional[int] = None,
+ metrics_enabled: bool = True,
+ agent_name_default: Optional[str] = None,
+ agent_id_default: Optional[str] = None,
+ agent_description_default: Optional[str] = None,
+ base_url_default: Optional[str] = None,
+ server_address_default: Optional[str] = None,
+ server_port_default: Optional[int] = None,
+ tracer_provider: Optional[Any] = None,
+ ):
+ """Initialize processor with metrics support.
+
+ Args:
+ tracer: Optional OpenTelemetry tracer
+ system_name: Provider name (openai/azure.ai.inference/etc.)
+ include_sensitive_data: Include model/tool IO when True
+ base_url: API endpoint for server.address/port
+ agent_name: Name of the agent (can be overridden by env var)
+ agent_id: ID of the agent (can be overridden by env var)
+ agent_description: Description of the agent (can be overridden by env var)
+ server_address: Server address (can be overridden by env var or base_url)
+ server_port: Server port (can be overridden by env var or base_url)
+ """
+ self._tracer = tracer
+ self.system_name = normalize_provider(system_name) or system_name
+ self._content_mode = content_mode
+ self.include_sensitive_data = include_sensitive_data and (
+ content_mode.capture_in_span or content_mode.capture_in_event
+ )
+ effective_base_url = base_url or base_url_default
+ self.base_url = effective_base_url
+
+ # Agent information - prefer explicit overrides; otherwise defer to span data
+ self.agent_name = agent_name
+ self.agent_id = agent_id
+ self.agent_description = agent_description
+ self._agent_name_default = agent_name_default
+ self._agent_id_default = agent_id_default
+ self._agent_description_default = agent_description_default
+
+ # Server information - use init parameters, then base_url inference
+ self.server_address = server_address or server_address_default
+ resolved_port = (
+ server_port if server_port is not None else server_port_default
+ )
+ self.server_port = resolved_port
+
+ # If server info not provided, try to extract from base_url
+ if (
+ not self.server_address or not self.server_port
+ ) and effective_base_url:
+ server_attrs = _infer_server_attributes(effective_base_url)
+ if not self.server_address:
+ self.server_address = server_attrs.get(
+ ServerAttributes.SERVER_ADDRESS
+ )
+ if not self.server_port:
+ self.server_port = server_attrs.get(
+ ServerAttributes.SERVER_PORT
+ )
+
+ # Content capture configuration
+ self._capture_messages = (
+ content_mode.capture_in_span or content_mode.capture_in_event
+ )
+ self._capture_system_instructions = True
+ self._capture_tool_definitions = True
+
+ # Span tracking
+ self._root_spans: dict[str, OtelSpan] = {}
+ self._otel_spans: dict[str, OtelSpan] = {}
+ self._tokens: dict[str, object] = {}
+ self._span_parents: dict[str, Optional[str]] = {}
+ self._agent_content: dict[str, Dict[str, list[Any]]] = {}
+
+ # util/genai integration (step 1 – workflow only)
+ # Pass tracer_provider to ensure handler uses correct resource/service name
+ self._handler = get_telemetry_handler(tracer_provider=tracer_provider)
+ self._workflow: Workflow | None = None
+ self._steps: dict[str, Step] = {}
+ self._llms: dict[str, LLMInvocation] = {}
+ self._tools: dict[str, ToolCall] = {}
+ # Track workflow input/output from agent spans
+ self._workflow_first_input: Optional[str] = None
+ self._workflow_last_output: Optional[str] = None
+ # Track agent spans per trace for auto-detecting multi-agent scenarios
+ self._trace_agent_count: dict[str, int] = {}
+ self._trace_first_agent_name: dict[str, str] = {}
+
+ # Register this processor globally for multi-agent workflow support
+ _GLOBAL_PROCESSOR_REF.clear()
+ _GLOBAL_PROCESSOR_REF.append(self)
+
+ # Metrics configuration
+ self._metrics_enabled = metrics_enabled
+ self._meter = None
+ self._duration_histogram: Optional[Histogram] = None
+ self._token_usage_histogram: Optional[Histogram] = None
+ if self._metrics_enabled:
+ self._init_metrics()
+
+ def _get_server_attributes(self) -> dict[str, Any]:
+ """Get server attributes from configured values."""
+ attrs = {}
+ if self.server_address:
+ attrs[ServerAttributes.SERVER_ADDRESS] = self.server_address
+ if self.server_port:
+ attrs[ServerAttributes.SERVER_PORT] = self.server_port
+ return attrs
+
+ def _init_metrics(self):
+ """Initialize metric instruments."""
+ self._meter = get_meter(
+ "opentelemetry.instrumentation.openai_agents", "0.1.0"
+ )
+
+ # Operation duration histogram
+ self._duration_histogram = self._meter.create_histogram(
+ name="gen_ai.client.operation.duration",
+ description="GenAI operation duration",
+ unit="s",
+ )
+
+ # Token usage histogram
+ self._token_usage_histogram = self._meter.create_histogram(
+ name="gen_ai.client.token.usage",
+ description="Number of input and output tokens used",
+ unit="{token}",
+ )
+
+ def _record_metrics(
+ self, span: Span[Any], attributes: dict[str, AttributeValue]
+ ) -> None:
+ """Record metrics for the span."""
+ if not self._metrics_enabled or (
+ self._duration_histogram is None
+ and self._token_usage_histogram is None
+ ):
+ return
+
+ try:
+ # Calculate duration
+ duration = None
+ if hasattr(span, "started_at") and hasattr(span, "ended_at"):
+ try:
+ start = datetime.fromisoformat(span.started_at)
+ end = datetime.fromisoformat(span.ended_at)
+ duration = (end - start).total_seconds()
+ except Exception:
+ pass
+
+ # Build metric attributes
+ metric_attrs = {
+ GEN_AI_PROVIDER_NAME: attributes.get(GEN_AI_PROVIDER_NAME),
+ GEN_AI_OPERATION_NAME: attributes.get(GEN_AI_OPERATION_NAME),
+ GEN_AI_REQUEST_MODEL: (
+ attributes.get(GEN_AI_REQUEST_MODEL)
+ or attributes.get(GEN_AI_RESPONSE_MODEL)
+ ),
+ ServerAttributes.SERVER_ADDRESS: attributes.get(
+ ServerAttributes.SERVER_ADDRESS
+ ),
+ ServerAttributes.SERVER_PORT: attributes.get(
+ ServerAttributes.SERVER_PORT
+ ),
+ }
+
+ # Add error type if present
+ if error := getattr(span, "error", None):
+ error_type = error.get("type") or error.get("name")
+ if error_type:
+ metric_attrs["error.type"] = error_type
+
+ # Remove None values
+ metric_attrs = {
+ k: v for k, v in metric_attrs.items() if v is not None
+ }
+
+ # Record duration
+ if duration is not None and self._duration_histogram is not None:
+ self._duration_histogram.record(duration, metric_attrs)
+
+ # Record token usage
+ if self._token_usage_histogram:
+ input_tokens = attributes.get(GEN_AI_USAGE_INPUT_TOKENS)
+ if isinstance(input_tokens, (int, float)):
+ token_attrs = dict(metric_attrs)
+ token_attrs[GEN_AI_TOKEN_TYPE] = "input"
+ self._token_usage_histogram.record(
+ input_tokens, token_attrs
+ )
+
+ output_tokens = attributes.get(GEN_AI_USAGE_OUTPUT_TOKENS)
+ if isinstance(output_tokens, (int, float)):
+ token_attrs = dict(metric_attrs)
+ token_attrs[GEN_AI_TOKEN_TYPE] = "output"
+ self._token_usage_histogram.record(
+ output_tokens, token_attrs
+ )
+
+ except Exception as e:
+ logger.debug("Failed to record metrics: %s", e)
+
+ def _emit_content_events(
+ self,
+ span: Span[Any],
+ otel_span: OtelSpan,
+ payload: ContentPayload,
+ agent_content: Optional[Dict[str, list[Any]]] = None,
+ ) -> None:
+ """Intentionally skip emitting gen_ai.* events to avoid payload duplication."""
+ if (
+ not self.include_sensitive_data
+ or not self._content_mode.capture_in_event
+ or not otel_span.is_recording()
+ ):
+ return
+
+ logger.debug(
+ "Event capture requested for span %s but is currently disabled",
+ getattr(span, "span_id", ""),
+ )
+ return
+
+ def _collect_system_instructions(
+ self, messages: Sequence[Any] | None
+ ) -> list[dict[str, str]]:
+ """Return system/ai role instructions as typed text objects.
+
+ Enforces format: [{"type": "text", "content": "..."}].
+ Handles message content that may be a string, list of parts,
+ or a dict with text/content fields.
+ """
+ if not messages:
+ return []
+ out: list[dict[str, str]] = []
+ for m in messages:
+ if not isinstance(m, dict):
+ continue
+ role = m.get("role")
+ if role in {"system", "ai"}:
+ content = m.get("content")
+ out.extend(self._normalize_to_text_parts(content))
+ return out
+
+ def _normalize_to_text_parts(self, content: Any) -> list[dict[str, str]]:
+ """Normalize arbitrary content into typed text parts.
+
+ - String -> [{type: text, content: }]
+ - List/Tuple -> map each item to a text part (string/dict supported)
+ - Dict -> use 'text' or 'content' field when available; else str(dict)
+ - Other -> str(value)
+ """
+ parts: list[dict[str, str]] = []
+ if content is None:
+ return parts
+ if isinstance(content, str):
+ parts.append({"type": "text", "content": content})
+ return parts
+ if isinstance(content, (list, tuple)):
+ for item in content:
+ if isinstance(item, str):
+ parts.append({"type": "text", "content": item})
+ elif isinstance(item, dict):
+ txt = item.get("text") or item.get("content")
+ if isinstance(txt, str) and txt:
+ parts.append({"type": "text", "content": txt})
+ else:
+ parts.append({"type": "text", "content": str(item)})
+ else:
+ parts.append({"type": "text", "content": str(item)})
+ return parts
+ if isinstance(content, dict):
+ txt = content.get("text") or content.get("content")
+ if isinstance(txt, str) and txt:
+ parts.append({"type": "text", "content": txt})
+ else:
+ parts.append({"type": "text", "content": str(content)})
+ return parts
+ # Fallback for other types
+ parts.append({"type": "text", "content": str(content)})
+ return parts
+
+ def _redacted_text_parts(self) -> list[dict[str, str]]:
+ """Return a single redacted text part for system instructions."""
+ return [{"type": "text", "content": "readacted"}]
+
+ def _normalize_messages_to_role_parts(
+ self, messages: Sequence[Any] | None
+ ) -> list[dict[str, Any]]:
+ """Normalize input messages to enforced role+parts schema.
+
+ Each message becomes: {"role": , "parts": [ {"type": ..., ...} ]}
+ Redaction: when include_sensitive_data is False, replace text content,
+ tool_call arguments, and tool_call_response result with "readacted".
+ """
+ if not messages:
+ return []
+ normalized: list[dict[str, Any]] = []
+ for m in messages:
+ if not isinstance(m, dict):
+ # Fallback: treat as user text
+ normalized.append(
+ {
+ "role": "user",
+ "parts": [
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else str(m)
+ ),
+ }
+ ],
+ }
+ )
+ continue
+
+ role = m.get("role") or "user"
+ parts: list[dict[str, Any]] = []
+
+ # Existing parts array
+ if isinstance(m.get("parts"), (list, tuple)):
+ for p in m["parts"]:
+ if isinstance(p, dict):
+ ptype = p.get("type") or "text"
+ newp: dict[str, Any] = {"type": ptype}
+ if ptype == "text":
+ txt = p.get("content") or p.get("text")
+ newp["content"] = (
+ "readacted"
+ if not self.include_sensitive_data
+ else (txt if isinstance(txt, str) else str(p))
+ )
+ elif ptype == "tool_call":
+ newp["id"] = p.get("id")
+ newp["name"] = p.get("name")
+ args = p.get("arguments")
+ newp["arguments"] = (
+ "readacted"
+ if not self.include_sensitive_data
+ else args
+ )
+ elif ptype == "tool_call_response":
+ newp["id"] = p.get("id") or m.get("tool_call_id")
+ result = p.get("result") or p.get("content")
+ newp["result"] = (
+ "readacted"
+ if not self.include_sensitive_data
+ else result
+ )
+ else:
+ newp["content"] = (
+ "readacted"
+ if not self.include_sensitive_data
+ else str(p)
+ )
+ parts.append(newp)
+ else:
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else str(p)
+ ),
+ }
+ )
+
+ # OpenAI content
+ content = m.get("content")
+ if isinstance(content, str):
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else content
+ ),
+ }
+ )
+ elif isinstance(content, (list, tuple)):
+ for item in content:
+ if isinstance(item, dict):
+ itype = item.get("type") or "text"
+ if itype == "text":
+ txt = item.get("text") or item.get("content")
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else (
+ txt
+ if isinstance(txt, str)
+ else str(item)
+ )
+ ),
+ }
+ )
+ else:
+ # Fallback for other part types
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else str(item)
+ ),
+ }
+ )
+ else:
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else str(item)
+ ),
+ }
+ )
+
+ # Assistant tool_calls
+ if role == "assistant" and isinstance(
+ m.get("tool_calls"), (list, tuple)
+ ):
+ for tc in m["tool_calls"]:
+ if not isinstance(tc, dict):
+ continue
+ p = {"type": "tool_call"}
+ p["id"] = tc.get("id")
+ fn = tc.get("function") or {}
+ if isinstance(fn, dict):
+ p["name"] = fn.get("name")
+ args = fn.get("arguments")
+ p["arguments"] = (
+ "readacted"
+ if not self.include_sensitive_data
+ else args
+ )
+ parts.append(p)
+
+ # Tool call response
+ if role in {"tool", "function"}:
+ p = {"type": "tool_call_response"}
+ p["id"] = m.get("tool_call_id") or m.get("id")
+ result = m.get("result") or m.get("content")
+ p["result"] = (
+ "readacted" if not self.include_sensitive_data else result
+ )
+ parts.append(p)
+
+ if parts:
+ normalized.append({"role": role, "parts": parts})
+ elif not self.include_sensitive_data:
+ normalized.append(
+ {"role": role, "parts": self._redacted_text_parts()}
+ )
+
+ return normalized
+
+ def _normalize_output_messages_to_role_parts(
+ self, span_data: Any
+ ) -> list[dict[str, Any]]:
+ """Normalize output messages to enforced role+parts schema.
+
+ Produces: [{"role": "assistant", "parts": [{"type": "text", "content": "..."}],
+ optional "finish_reason": "..." }]
+ """
+ messages: list[dict[str, Any]] = []
+ parts: list[dict[str, Any]] = []
+ finish_reason: Optional[str] = None
+
+ # Response span: prefer consolidated output_text
+ response = getattr(span_data, "response", None)
+ if response is not None:
+ # Collect text content
+ output_text = getattr(response, "output_text", None)
+ if isinstance(output_text, str) and output_text:
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else output_text
+ ),
+ }
+ )
+ else:
+ output = getattr(response, "output", None)
+ if isinstance(output, Sequence):
+ for item in output:
+ # ResponseOutputMessage may have a string representation
+ txt = getattr(item, "content", None)
+ if isinstance(txt, str) and txt:
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else txt
+ ),
+ }
+ )
+ else:
+ # Fallback: stringified
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else str(item)
+ ),
+ }
+ )
+ # Capture finish_reason from parts when present
+ fr = getattr(item, "finish_reason", None)
+ if isinstance(fr, str) and not finish_reason:
+ finish_reason = fr
+
+ # Generation span: use span_data.output
+ if not parts:
+ output = getattr(span_data, "output", None)
+ if isinstance(output, Sequence):
+ for item in output:
+ if isinstance(item, dict):
+ if item.get("type") == "text":
+ txt = item.get("content") or item.get("text")
+ if isinstance(txt, str) and txt:
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else txt
+ ),
+ }
+ )
+ elif "content" in item and isinstance(
+ item["content"], str
+ ):
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else item["content"]
+ ),
+ }
+ )
+ else:
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else str(item)
+ ),
+ }
+ )
+ if not finish_reason and isinstance(
+ item.get("finish_reason"), str
+ ):
+ finish_reason = item.get("finish_reason")
+ elif isinstance(item, str):
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else item
+ ),
+ }
+ )
+ else:
+ parts.append(
+ {
+ "type": "text",
+ "content": (
+ "readacted"
+ if not self.include_sensitive_data
+ else str(item)
+ ),
+ }
+ )
+
+ # Build assistant message
+ msg: dict[str, Any] = {"role": "assistant", "parts": parts}
+ if finish_reason:
+ msg["finish_reason"] = finish_reason
+ # Only include if there is content
+ if parts:
+ messages.append(msg)
+ return messages
+
+ def _build_content_payload(self, span: Span[Any]) -> ContentPayload:
+ """Normalize content from span data for attribute/event capture."""
+ payload = ContentPayload()
+ span_data = getattr(span, "span_data", None)
+ if span_data is None or not self.include_sensitive_data:
+ return payload
+
+ capture_messages = self._capture_messages and (
+ self._content_mode.capture_in_span
+ or self._content_mode.capture_in_event
+ )
+ capture_system = self._capture_system_instructions and (
+ self._content_mode.capture_in_span
+ or self._content_mode.capture_in_event
+ )
+ capture_tools = self._content_mode.capture_in_span or (
+ self._content_mode.capture_in_event
+ and _is_instance_of(span_data, FunctionSpanData)
+ )
+
+ if _is_instance_of(span_data, GenerationSpanData):
+ span_input = getattr(span_data, "input", None)
+ if capture_messages and span_input:
+ payload.input_messages = (
+ self._normalize_messages_to_role_parts(span_input)
+ )
+ if capture_system and span_input:
+ sys_instr = self._collect_system_instructions(span_input)
+ if sys_instr:
+ payload.system_instructions = sys_instr
+ if capture_messages and (
+ getattr(span_data, "output", None)
+ or getattr(span_data, "response", None)
+ ):
+ normalized_out = self._normalize_output_messages_to_role_parts(
+ span_data
+ )
+ if normalized_out:
+ payload.output_messages = normalized_out
+
+ elif _is_instance_of(span_data, ResponseSpanData):
+ span_input = getattr(span_data, "input", None)
+ if capture_messages and span_input:
+ payload.input_messages = (
+ self._normalize_messages_to_role_parts(span_input)
+ )
+ if capture_system and span_input:
+ sys_instr = self._collect_system_instructions(span_input)
+ if sys_instr:
+ payload.system_instructions = sys_instr
+ if capture_messages:
+ normalized_out = self._normalize_output_messages_to_role_parts(
+ span_data
+ )
+ if normalized_out:
+ payload.output_messages = normalized_out
+
+ elif _is_instance_of(span_data, FunctionSpanData) and capture_tools:
+
+ def _serialize_tool_value(value: Any) -> Optional[str]:
+ if value is None:
+ return None
+ if isinstance(value, (dict, list)):
+ return safe_json_dumps(value)
+ return str(value)
+
+ payload.tool_arguments = _serialize_tool_value(
+ getattr(span_data, "input", None)
+ )
+ payload.tool_result = _serialize_tool_value(
+ getattr(span_data, "output", None)
+ )
+
+ return payload
+
+ def _find_agent_parent_span_id(
+ self, span_id: Optional[str]
+ ) -> Optional[str]:
+ """Return nearest ancestor span id that represents an agent."""
+ current = span_id
+ visited: set[str] = set()
+ while current:
+ if current in visited:
+ break
+ visited.add(current)
+ if current in self._agent_content:
+ return current
+ current = self._span_parents.get(current)
+ return None
+
+ def _update_agent_aggregate(
+ self, span: Span[Any], payload: ContentPayload
+ ) -> None:
+ """Accumulate child span content for parent agent span."""
+ agent_id = self._find_agent_parent_span_id(span.parent_id)
+ if not agent_id:
+ return
+ entry = self._agent_content.setdefault(
+ agent_id,
+ {
+ "input_messages": [],
+ "output_messages": [],
+ "system_instructions": [],
+ "request_model": None,
+ },
+ )
+ if payload.input_messages:
+ entry["input_messages"] = self._merge_content_sequence(
+ entry["input_messages"], payload.input_messages
+ )
+ if payload.output_messages:
+ entry["output_messages"] = self._merge_content_sequence(
+ entry["output_messages"], payload.output_messages
+ )
+ if payload.system_instructions:
+ entry["system_instructions"] = self._merge_content_sequence(
+ entry["system_instructions"], payload.system_instructions
+ )
+
+ if not entry.get("request_model"):
+ model = getattr(span.span_data, "model", None)
+ if not model:
+ response_obj = getattr(span.span_data, "response", None)
+ model = getattr(response_obj, "model", None)
+ if model:
+ entry["request_model"] = model
+
+ def _infer_output_type(self, span_data: Any) -> str:
+ """Infer gen_ai.output.type for multiple span kinds."""
+ if _is_instance_of(span_data, FunctionSpanData):
+ # Tool results are typically JSON
+ return GenAIOutputType.JSON
+ if _is_instance_of(span_data, TranscriptionSpanData):
+ return GenAIOutputType.TEXT
+ if _is_instance_of(span_data, SpeechSpanData):
+ return GenAIOutputType.SPEECH
+ if _is_instance_of(span_data, GuardrailSpanData):
+ return GenAIOutputType.TEXT
+ if _is_instance_of(span_data, HandoffSpanData):
+ return GenAIOutputType.TEXT
+
+ # Check for embeddings operation
+ if _is_instance_of(span_data, GenerationSpanData):
+ if hasattr(span_data, "embedding_dimension"):
+ return (
+ GenAIOutputType.TEXT
+ ) # Embeddings are numeric but represented as text
+
+ # Generation/Response - check output structure
+ output = getattr(span_data, "output", None) or getattr(
+ getattr(span_data, "response", None), "output", None
+ )
+ if isinstance(output, Sequence) and output:
+ first = output[0]
+ if isinstance(first, dict):
+ item_type = first.get("type")
+ if isinstance(item_type, str):
+ normalized = item_type.strip().lower()
+ if normalized in {"image", "image_url"}:
+ return GenAIOutputType.IMAGE
+ if normalized in {"audio", "speech", "audio_url"}:
+ return GenAIOutputType.SPEECH
+ if normalized in {
+ "json",
+ "json_object",
+ "jsonschema",
+ "function_call",
+ "tool_call",
+ "tool_result",
+ }:
+ return GenAIOutputType.JSON
+ if normalized in {
+ "text",
+ "output_text",
+ "message",
+ "assistant",
+ }:
+ return GenAIOutputType.TEXT
+
+ # Conversation style payloads
+ if "role" in first:
+ parts = first.get("parts")
+ if isinstance(parts, Sequence) and parts:
+ # If all parts are textual (or missing explicit type), treat as text
+ textual = True
+ for part in parts:
+ if isinstance(part, dict):
+ part_type = str(part.get("type", "")).lower()
+ if part_type in {"image", "image_url"}:
+ return GenAIOutputType.IMAGE
+ if part_type in {
+ "audio",
+ "speech",
+ "audio_url",
+ }:
+ return GenAIOutputType.SPEECH
+ if part_type and part_type not in {
+ "text",
+ "output_text",
+ "assistant",
+ }:
+ textual = False
+ elif not isinstance(part, str):
+ textual = False
+ if textual:
+ return GenAIOutputType.TEXT
+ content_value = first.get("content")
+ if isinstance(content_value, str):
+ return GenAIOutputType.TEXT
+
+ # Detect structured data without explicit type
+ json_like_keys = {
+ "schema",
+ "properties",
+ "arguments",
+ "result",
+ "data",
+ "json",
+ "output_json",
+ }
+ if json_like_keys.intersection(first.keys()):
+ return GenAIOutputType.JSON
+
+ return GenAIOutputType.TEXT
+
+ @staticmethod
+ def _sanitize_usage_payload(usage: Any) -> None:
+ """Remove non-spec usage fields (e.g., total tokens) in-place."""
+ if not usage:
+ return
+ if isinstance(usage, dict):
+ usage.pop("total_tokens", None)
+ return
+ if hasattr(usage, "total_tokens"):
+ try:
+ setattr(usage, "total_tokens", None)
+ except Exception: # pragma: no cover - defensive
+ try:
+ delattr(usage, "total_tokens")
+ except Exception: # pragma: no cover - defensive
+ pass
+
+ def _get_span_kind(self, span_data: Any) -> SpanKind:
+ """Determine appropriate span kind based on span data type."""
+ if _is_instance_of(span_data, FunctionSpanData):
+ return SpanKind.INTERNAL # Tool execution is internal
+ if _is_instance_of(
+ span_data,
+ (
+ GenerationSpanData,
+ ResponseSpanData,
+ TranscriptionSpanData,
+ SpeechSpanData,
+ ),
+ ):
+ return SpanKind.CLIENT # API calls to model providers
+ if _is_instance_of(span_data, AgentSpanData):
+ return SpanKind.CLIENT
+ if _is_instance_of(span_data, (GuardrailSpanData, HandoffSpanData)):
+ return SpanKind.INTERNAL # Agent operations are internal
+ return SpanKind.INTERNAL
+
+ def on_trace_start(self, trace: Trace) -> None:
+ """Create root span when trace starts."""
+ if self._tracer:
+ try:
+ # If global workflow context is active, workflow was already started
+ # by start_multi_agent_workflow() - don't create a new one
+ if _GLOBAL_WORKFLOW_CONTEXT.get("active"):
+ return
+
+ # Initialize agent tracking for this trace
+ self._trace_agent_count[trace.trace_id] = 0
+ self._trace_first_agent_name.pop(trace.trace_id, None)
+
+ # Reset workflow input/output tracking for new trace
+ self._workflow_first_input = None
+ self._workflow_last_output = None
+
+ # Workflow will be created lazily on first agent span
+ # This allows us to use the first agent's name for the workflow
+ # Similar to LangChain's approach
+ self._workflow = None
+ except Exception: # defensive – don't break existing spans
+ self._workflow = None
+
+ def on_trace_end(self, trace: Trace) -> None:
+ """End root span when trace ends."""
+ if root_span := self._root_spans.pop(trace.trace_id, None):
+ if root_span.is_recording():
+ root_span.set_status(Status(StatusCode.OK))
+ root_span.end()
+
+ # Clean up trace-level tracking
+ self._trace_agent_count.pop(trace.trace_id, None)
+ self._trace_first_agent_name.pop(trace.trace_id, None)
+
+ # Only stop workflow if not in global workflow context
+ if self._workflow is not None and not _GLOBAL_WORKFLOW_CONTEXT.get(
+ "active"
+ ):
+ # Set input/output from tracked agent spans
+ if self._workflow_first_input and not self._workflow.initial_input:
+ self._workflow.initial_input = self._workflow_first_input
+ if self._workflow_last_output:
+ self._workflow.final_output = self._workflow_last_output
+ self._handler.stop_workflow(self._workflow)
+ self._workflow = None
+
+ def on_span_start(self, span: Span[Any]) -> None:
+ """Start child span for agent span."""
+ if not self._tracer or not span.started_at:
+ return
+
+ self._span_parents[span.span_id] = span.parent_id
+ if (
+ _is_instance_of(span.span_data, AgentSpanData)
+ and span.span_id not in self._agent_content
+ ):
+ self._agent_content[span.span_id] = {
+ "input_messages": [],
+ "output_messages": [],
+ "system_instructions": [],
+ "request_model": None,
+ }
+
+ parent_span = (
+ self._otel_spans.get(span.parent_id)
+ if span.parent_id
+ else self._root_spans.get(span.trace_id)
+ )
+ context = set_span_in_context(parent_span) if parent_span else None
+
+ # Get operation details for span naming
+ operation_name = self._get_operation_name(span.span_data)
+ model = getattr(span.span_data, "model", None)
+ if model is None:
+ response_obj = getattr(span.span_data, "response", None)
+ model = getattr(response_obj, "model", None)
+
+ # Use configured agent name or get from span data
+ agent_name = self.agent_name
+ if not agent_name and _is_instance_of(span.span_data, AgentSpanData):
+ agent_name = getattr(span.span_data, "name", None)
+ if not agent_name:
+ agent_name = self._agent_name_default
+
+ tool_name = (
+ getattr(span.span_data, "name", None)
+ if _is_instance_of(span.span_data, FunctionSpanData)
+ else None
+ )
+
+ # Generate spec-compliant span name
+ span_name = get_span_name(operation_name, model, agent_name, tool_name)
+
+ attributes = {
+ GEN_AI_PROVIDER_NAME: self.system_name,
+ GEN_AI_SYSTEM_KEY: self.system_name,
+ GEN_AI_OPERATION_NAME: operation_name,
+ }
+ # Legacy emission removed
+
+ # Add configured agent and server attributes
+ agent_name_override = self.agent_name or self._agent_name_default
+ agent_id_override = self.agent_id or self._agent_id_default
+ agent_desc_override = (
+ self.agent_description or self._agent_description_default
+ )
+ if agent_name_override:
+ attributes[GEN_AI_AGENT_NAME] = agent_name_override
+ if agent_id_override:
+ attributes[GEN_AI_AGENT_ID] = agent_id_override
+ if agent_desc_override:
+ attributes[GEN_AI_AGENT_DESCRIPTION] = agent_desc_override
+ attributes.update(self._get_server_attributes())
+
+ # For agent spans, create a GenAI Step under the workflow and
+ # make the OpenAI Agents span a child of that Step span.
+ # Auto-detect multi-agent: lazily create workflow on first agent span
+ if _is_instance_of(span.span_data, AgentSpanData):
+ # Track agent count for this trace (auto-detect multi-agent)
+ trace_id = span.trace_id
+ if trace_id in self._trace_agent_count:
+ self._trace_agent_count[trace_id] += 1
+ else:
+ self._trace_agent_count[trace_id] = 1
+
+ # Store first agent name for workflow naming
+ if trace_id not in self._trace_first_agent_name and agent_name:
+ self._trace_first_agent_name[trace_id] = agent_name
+
+ # Lazily create workflow on first agent span (similar to LangChain)
+ if self._workflow is None and not _GLOBAL_WORKFLOW_CONTEXT.get(
+ "active"
+ ):
+ try:
+ # Use first agent's name for workflow, or default
+ workflow_name = self._trace_first_agent_name.get(
+ trace_id, agent_name or "OpenAIAgents"
+ )
+ self._workflow = Workflow(
+ name=workflow_name, attributes={}
+ )
+ self._handler.start_workflow(self._workflow)
+ except Exception:
+ self._workflow = None
+
+ if (
+ _is_instance_of(span.span_data, AgentSpanData)
+ and self._workflow is not None
+ ):
+ try:
+ step_attrs: dict[str, Any] = dict(attributes)
+ step = Step(
+ name=agent_name or span_name,
+ step_type="agent_start", # or "chain" if you prefer
+ attributes=step_attrs,
+ )
+
+ step.parent_run_id = self._workflow.run_id
+
+ content = self._agent_content.get(span.span_id)
+ if content and content.get("input_messages"):
+ step.input_data = safe_json_dumps(
+ content["input_messages"]
+ )
+
+ self._handler.start_step(step)
+ self._steps[str(span.span_id)] = step
+
+ # Use the Step's span as parent context for the OpenAI Agents span.
+ parent_span = get_current_span()
+ context = set_span_in_context(parent_span)
+ except Exception:
+ pass
+
+ otel_span = self._tracer.start_span(
+ name=span_name,
+ context=context,
+ attributes=attributes,
+ kind=self._get_span_kind(span.span_data),
+ )
+ self._otel_spans[span.span_id] = otel_span
+ self._tokens[span.span_id] = attach(set_span_in_context(otel_span))
+
+ if _is_instance_of(span.span_data, AgentSpanData):
+ try:
+ agent_attrs: dict[str, Any] = dict(attributes)
+
+ agent_entity = AgentInvocation(
+ name=agent_name or span_name,
+ attributes=agent_attrs,
+ )
+
+ if self._workflow is not None:
+ agent_entity.parent_run_id = self._workflow.run_id
+
+ content = self._agent_content.get(span.span_id)
+ if content and content.get("input_messages"):
+ agent_entity.input_context = safe_json_dumps(
+ content["input_messages"]
+ )
+
+ if agent_name:
+ agent_entity.agent_name = agent_name
+
+ agent_entity.framework = "openai_agents"
+
+ self._handler.start_agent(agent_entity)
+ except Exception:
+ pass
+
+ if _is_instance_of(span.span_data, GenerationSpanData):
+ try:
+ llm_attrs: dict[str, Any] = dict(attributes)
+
+ request_model = model or str(
+ attributes.get(GEN_AI_REQUEST_MODEL, "unknown_model")
+ )
+
+ llm_entity = LLMInvocation(
+ name=span_name,
+ request_model=request_model,
+ attributes=llm_attrs,
+ )
+
+ parent_run_id = None
+ parent_step = None
+ if span.parent_id is not None:
+ parent_step = self._steps.get(str(span.parent_id))
+ if parent_step is not None:
+ parent_run_id = parent_step.run_id
+ elif self._workflow is not None:
+ parent_run_id = self._workflow.run_id
+ if parent_run_id is not None:
+ llm_entity.parent_run_id = parent_run_id
+
+ self._handler.start_llm(llm_entity)
+ self._llms[str(span.span_id)] = llm_entity
+ except Exception:
+ pass
+
+ if _is_instance_of(span.span_data, FunctionSpanData):
+ try:
+ tool_attrs: dict[str, Any] = dict(attributes)
+
+ content = self._agent_content.get(span.span_id)
+ tool_args = None
+ if content is not None:
+ tool_args = content.get("tool_arguments")
+
+ tool_entity = ToolCall(
+ name=getattr(span.span_data, "name", span_name),
+ id=None,
+ arguments=tool_args,
+ attributes=tool_attrs,
+ )
+
+ # Parent to enclosing Step when available, otherwise to Workflow.
+ parent_run_id = None
+ parent_step = None
+ if span.parent_id is not None:
+ parent_step = self._steps.get(str(span.parent_id))
+ if parent_step is not None:
+ parent_run_id = parent_step.run_id
+ elif self._workflow is not None:
+ parent_run_id = self._workflow.run_id
+ if parent_run_id is not None:
+ tool_entity.parent_run_id = parent_run_id
+
+ tool_entity.framework = "openai_agents"
+ self._handler.start_tool_call(tool_entity)
+ self._tools[str(span.span_id)] = tool_entity
+ except Exception:
+ # Defensive: do not break span creation if util/genai fails.
+ pass
+
+ def on_span_end(self, span: Span[Any]) -> None:
+ """Finalize span with attributes, events, and metrics."""
+ if token := self._tokens.pop(span.span_id, None):
+ detach(token)
+
+ payload = self._build_content_payload(span)
+ self._update_agent_aggregate(span, payload)
+ agent_content = (
+ self._agent_content.get(span.span_id)
+ if _is_instance_of(span.span_data, AgentSpanData)
+ else None
+ )
+
+ if not (otel_span := self._otel_spans.pop(span.span_id, None)):
+ # Log attributes even without OTel span
+ try:
+ attributes = dict(
+ self._extract_genai_attributes(
+ span, payload, agent_content
+ )
+ )
+ for key, value in attributes.items():
+ logger.debug(
+ "GenAI attr span %s: %s=%s", span.span_id, key, value
+ )
+ except Exception as e:
+ logger.warning(
+ "Failed to extract attributes for span %s: %s",
+ span.span_id,
+ e,
+ )
+ if _is_instance_of(span.span_data, AgentSpanData):
+ self._agent_content.pop(span.span_id, None)
+ self._span_parents.pop(span.span_id, None)
+ return
+ key = str(span.span_id)
+ step = self._steps.pop(key, None)
+ if step is not None:
+ try:
+ # Attach input and output from agent/span
+ content = self._agent_content.get(span.span_id)
+ if content:
+ if content.get("input_messages") and not step.input_data:
+ step.input_data = safe_json_dumps(
+ content["input_messages"]
+ )
+ # Track first input for workflow (default workflow path)
+ if self._workflow_first_input is None:
+ self._workflow_first_input = step.input_data
+ if content.get("output_messages"):
+ step.output_data = safe_json_dumps(
+ content["output_messages"]
+ )
+ # Track last output for workflow (default workflow path)
+ self._workflow_last_output = step.output_data
+ self._handler.stop_step(step)
+ except Exception:
+ pass
+ # Stop any LLMInvocation associated with this span.
+ llm_entity = self._llms.pop(key, None)
+ if llm_entity is not None:
+ try:
+ content = self._agent_content.get(span.span_id)
+ if content and content.get("output_messages"):
+ llm_entity.output_messages = []
+ self._handler.stop_llm(llm_entity)
+ except Exception:
+ pass
+ # Stop any ToolCall associated with this span.
+ tool_entity = self._tools.pop(key, None)
+ if tool_entity is not None:
+ try:
+ content = self._agent_content.get(span.span_id)
+ if content and content.get("tool_result") is not None:
+ serialized = safe_json_dumps(content["tool_result"])
+ tool_entity.attributes.setdefault(
+ "tool.response", serialized
+ )
+ self._handler.stop_tool_call(tool_entity)
+ except Exception:
+ pass
+ try:
+ # Extract and set attributes
+ attributes: dict[str, AttributeValue] = {}
+ # Optimize for non-sampled spans to avoid heavy work
+ if not otel_span.is_recording():
+ otel_span.end()
+ return
+ for key, value in self._extract_genai_attributes(
+ span, payload, agent_content
+ ):
+ otel_span.set_attribute(key, value)
+ attributes[key] = value
+
+ if _is_instance_of(
+ span.span_data, (GenerationSpanData, ResponseSpanData)
+ ):
+ operation_name = attributes.get(GEN_AI_OPERATION_NAME)
+ model_for_name = attributes.get(GEN_AI_REQUEST_MODEL) or (
+ attributes.get(GEN_AI_RESPONSE_MODEL)
+ )
+ if operation_name and model_for_name:
+ agent_name_for_name = attributes.get(GEN_AI_AGENT_NAME)
+ tool_name_for_name = attributes.get(GEN_AI_TOOL_NAME)
+ new_name = get_span_name(
+ operation_name,
+ model_for_name,
+ agent_name_for_name,
+ tool_name_for_name,
+ )
+ if new_name != otel_span.name:
+ otel_span.update_name(new_name)
+
+ # Emit span events for captured content when configured
+ self._emit_content_events(span, otel_span, payload, agent_content)
+
+ # Emit operation details event if configured
+ # Set error status if applicable
+ otel_span.set_status(status=_get_span_status(span))
+ if getattr(span, "error", None):
+ err_obj = span.error
+ err_type = err_obj.get("type") or err_obj.get("name")
+ if err_type:
+ otel_span.set_attribute("error.type", err_type)
+
+ # Record metrics before ending span
+ self._record_metrics(span, attributes)
+
+ # End the span
+ otel_span.end()
+
+ except Exception as e:
+ logger.warning("Failed to enrich span %s: %s", span.span_id, e)
+ otel_span.set_status(Status(StatusCode.ERROR, str(e)))
+ otel_span.end()
+ finally:
+ if _is_instance_of(span.span_data, AgentSpanData):
+ self._agent_content.pop(span.span_id, None)
+ self._span_parents.pop(span.span_id, None)
+
+ def shutdown(self) -> None:
+ """Clean up resources on shutdown."""
+ for span_id, otel_span in list(self._otel_spans.items()):
+ otel_span.set_status(
+ Status(StatusCode.ERROR, "Application shutdown")
+ )
+ otel_span.end()
+
+ for trace_id, root_span in list(self._root_spans.items()):
+ root_span.set_status(
+ Status(StatusCode.ERROR, "Application shutdown")
+ )
+ root_span.end()
+
+ self._otel_spans.clear()
+ self._root_spans.clear()
+ self._tokens.clear()
+ self._span_parents.clear()
+ self._agent_content.clear()
+
+ def force_flush(self) -> None:
+ """Force flush (no-op for this processor)."""
+ pass
+
+ def _get_operation_name(self, span_data: Any) -> str:
+ """Determine operation name from span data type."""
+ if _is_instance_of(span_data, GenerationSpanData):
+ # Check if it's embeddings
+ if hasattr(span_data, "embedding_dimension"):
+ return GenAIOperationName.EMBEDDINGS
+ # Check if it's chat or completion
+ if span_data.input:
+ first_input = span_data.input[0] if span_data.input else None
+ if isinstance(first_input, dict) and "role" in first_input:
+ return GenAIOperationName.CHAT
+ return GenAIOperationName.TEXT_COMPLETION
+ if _is_instance_of(span_data, AgentSpanData):
+ # Could be create_agent or invoke_agent based on context
+ operation = getattr(span_data, "operation", None)
+ normalized = (
+ operation.strip().lower()
+ if isinstance(operation, str)
+ else None
+ )
+ if normalized in {"create", "create_agent"}:
+ return GenAIOperationName.CREATE_AGENT
+ if normalized in {"invoke", "invoke_agent"}:
+ return GenAIOperationName.INVOKE_AGENT
+ return GenAIOperationName.INVOKE_AGENT
+ if _is_instance_of(span_data, FunctionSpanData):
+ return GenAIOperationName.EXECUTE_TOOL
+ if _is_instance_of(span_data, ResponseSpanData):
+ return GenAIOperationName.CHAT # Response typically from chat
+ if _is_instance_of(span_data, TranscriptionSpanData):
+ return GenAIOperationName.TRANSCRIPTION
+ if _is_instance_of(span_data, SpeechSpanData):
+ return GenAIOperationName.SPEECH
+ if _is_instance_of(span_data, GuardrailSpanData):
+ return GenAIOperationName.GUARDRAIL
+ if _is_instance_of(span_data, HandoffSpanData):
+ return GenAIOperationName.HANDOFF
+ return "unknown"
+
+ def _extract_genai_attributes(
+ self,
+ span: Span[Any],
+ payload: ContentPayload,
+ agent_content: Optional[Dict[str, list[Any]]] = None,
+ ) -> Iterator[tuple[str, AttributeValue]]:
+ """Yield (attr, value) pairs for GenAI semantic conventions."""
+ span_data = span.span_data
+
+ # Base attributes
+ yield GEN_AI_PROVIDER_NAME, self.system_name
+ yield GEN_AI_SYSTEM_KEY, self.system_name
+ # Legacy emission removed
+
+ # Add configured agent attributes (always include when set)
+ agent_name_override = self.agent_name or self._agent_name_default
+ agent_id_override = self.agent_id or self._agent_id_default
+ agent_desc_override = (
+ self.agent_description or self._agent_description_default
+ )
+ if agent_name_override:
+ yield GEN_AI_AGENT_NAME, agent_name_override
+ if agent_id_override:
+ yield GEN_AI_AGENT_ID, agent_id_override
+ if agent_desc_override:
+ yield GEN_AI_AGENT_DESCRIPTION, agent_desc_override
+
+ # Server attributes
+ for key, value in self._get_server_attributes().items():
+ yield key, value
+
+ # Process different span types
+ if _is_instance_of(span_data, GenerationSpanData):
+ yield from self._get_attributes_from_generation_span_data(
+ span_data, payload
+ )
+ elif _is_instance_of(span_data, AgentSpanData):
+ yield from self._get_attributes_from_agent_span_data(
+ span_data, agent_content
+ )
+ elif _is_instance_of(span_data, FunctionSpanData):
+ yield from self._get_attributes_from_function_span_data(
+ span_data, payload
+ )
+ elif _is_instance_of(span_data, ResponseSpanData):
+ yield from self._get_attributes_from_response_span_data(
+ span_data, payload
+ )
+ elif _is_instance_of(span_data, TranscriptionSpanData):
+ yield from self._get_attributes_from_transcription_span_data(
+ span_data
+ )
+ elif _is_instance_of(span_data, SpeechSpanData):
+ yield from self._get_attributes_from_speech_span_data(span_data)
+ elif _is_instance_of(span_data, GuardrailSpanData):
+ yield from self._get_attributes_from_guardrail_span_data(span_data)
+ elif _is_instance_of(span_data, HandoffSpanData):
+ yield from self._get_attributes_from_handoff_span_data(span_data)
+
+ def _get_attributes_from_generation_span_data(
+ self, span_data: GenerationSpanData, payload: ContentPayload
+ ) -> Iterator[tuple[str, AttributeValue]]:
+ """Extract attributes from generation span."""
+ # Operation name
+ operation_name = self._get_operation_name(span_data)
+ yield GEN_AI_OPERATION_NAME, operation_name
+
+ # Model information
+ if span_data.model:
+ yield GEN_AI_REQUEST_MODEL, span_data.model
+
+ # Check for embeddings-specific attributes
+ if hasattr(span_data, "embedding_dimension"):
+ yield (
+ GEN_AI_EMBEDDINGS_DIMENSION_COUNT,
+ span_data.embedding_dimension,
+ )
+
+ # Check for data source
+ if hasattr(span_data, "data_source_id"):
+ yield GEN_AI_DATA_SOURCE_ID, span_data.data_source_id
+
+ finish_reasons: list[Any] = []
+ if span_data.output:
+ for part in span_data.output:
+ if isinstance(part, dict):
+ fr = part.get("finish_reason") or part.get("stop_reason")
+ else:
+ fr = getattr(part, "finish_reason", None)
+ if fr:
+ finish_reasons.append(
+ fr if isinstance(fr, str) else str(fr)
+ )
+ if finish_reasons:
+ yield GEN_AI_RESPONSE_FINISH_REASONS, finish_reasons
+
+ # Usage information
+ if span_data.usage:
+ usage = span_data.usage
+ self._sanitize_usage_payload(usage)
+ if "prompt_tokens" in usage or "input_tokens" in usage:
+ tokens = usage.get("prompt_tokens") or usage.get(
+ "input_tokens"
+ )
+ if tokens is not None:
+ yield GEN_AI_USAGE_INPUT_TOKENS, tokens
+ if "completion_tokens" in usage or "output_tokens" in usage:
+ tokens = usage.get("completion_tokens") or usage.get(
+ "output_tokens"
+ )
+ if tokens is not None:
+ yield GEN_AI_USAGE_OUTPUT_TOKENS, tokens
+
+ # Model configuration
+ if span_data.model_config:
+ mc = span_data.model_config
+ param_map = {
+ "temperature": GEN_AI_REQUEST_TEMPERATURE,
+ "top_p": GEN_AI_REQUEST_TOP_P,
+ "top_k": GEN_AI_REQUEST_TOP_K,
+ "max_tokens": GEN_AI_REQUEST_MAX_TOKENS,
+ "presence_penalty": GEN_AI_REQUEST_PRESENCE_PENALTY,
+ "frequency_penalty": GEN_AI_REQUEST_FREQUENCY_PENALTY,
+ "seed": GEN_AI_REQUEST_SEED,
+ "n": GEN_AI_REQUEST_CHOICE_COUNT,
+ "stop": GEN_AI_REQUEST_STOP_SEQUENCES,
+ "encoding_formats": GEN_AI_REQUEST_ENCODING_FORMATS,
+ }
+ for k, attr in param_map.items():
+ if hasattr(mc, "__contains__") and k in mc:
+ value = mc[k]
+ else:
+ value = getattr(mc, k, None)
+ if value is not None:
+ yield attr, value
+
+ if hasattr(mc, "get"):
+ base_url = (
+ mc.get("base_url")
+ or mc.get("baseUrl")
+ or mc.get("endpoint")
+ )
+ else:
+ base_url = (
+ getattr(mc, "base_url", None)
+ or getattr(mc, "baseUrl", None)
+ or getattr(mc, "endpoint", None)
+ )
+ for key, value in _infer_server_attributes(base_url).items():
+ yield key, value
+
+ # Sensitive data capture
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and self._capture_messages
+ and payload.input_messages
+ ):
+ yield (
+ GEN_AI_INPUT_MESSAGES,
+ safe_json_dumps(payload.input_messages),
+ )
+
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and self._capture_system_instructions
+ and payload.system_instructions
+ ):
+ yield (
+ GEN_AI_SYSTEM_INSTRUCTIONS,
+ safe_json_dumps(payload.system_instructions),
+ )
+
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and self._capture_messages
+ and payload.output_messages
+ ):
+ yield (
+ GEN_AI_OUTPUT_MESSAGES,
+ safe_json_dumps(payload.output_messages),
+ )
+
+ # Output type
+ yield (
+ GEN_AI_OUTPUT_TYPE,
+ normalize_output_type(self._infer_output_type(span_data)),
+ )
+
+ def _merge_content_sequence(
+ self,
+ existing: list[Any],
+ incoming: Sequence[Any],
+ ) -> list[Any]:
+ """Merge normalized message/content lists without duplicating snapshots."""
+ if not incoming:
+ return existing
+
+ incoming_list = [self._clone_message(item) for item in incoming]
+
+ if self.include_sensitive_data:
+ filtered = [
+ msg
+ for msg in incoming_list
+ if not self._is_placeholder_message(msg)
+ ]
+ if filtered:
+ incoming_list = filtered
+
+ if not existing:
+ return incoming_list
+
+ result = [self._clone_message(item) for item in existing]
+
+ for idx, new_msg in enumerate(incoming_list):
+ if idx < len(result):
+ if (
+ self.include_sensitive_data
+ and self._is_placeholder_message(new_msg)
+ and not self._is_placeholder_message(result[idx])
+ ):
+ continue
+ if result[idx] != new_msg:
+ result[idx] = self._clone_message(new_msg)
+ else:
+ if (
+ self.include_sensitive_data
+ and self._is_placeholder_message(new_msg)
+ ):
+ if (
+ any(
+ not self._is_placeholder_message(existing_msg)
+ for existing_msg in result
+ )
+ or new_msg in result
+ ):
+ continue
+ result.append(self._clone_message(new_msg))
+
+ return result
+
+ def _clone_message(self, message: Any) -> Any:
+ if isinstance(message, dict):
+ return {
+ key: (
+ self._clone_message(value)
+ if isinstance(value, (dict, list))
+ else value
+ )
+ for key, value in message.items()
+ }
+ if isinstance(message, list):
+ return [self._clone_message(item) for item in message]
+ return message
+
+ def _is_placeholder_message(self, message: Any) -> bool:
+ if not isinstance(message, dict):
+ return False
+ parts = message.get("parts")
+ if not isinstance(parts, list) or not parts:
+ return False
+ for part in parts:
+ if (
+ not isinstance(part, dict)
+ or part.get("type") != "text"
+ or part.get("content") != "readacted"
+ ):
+ return False
+ return True
+
+ def _get_attributes_from_agent_span_data(
+ self,
+ span_data: AgentSpanData,
+ agent_content: Optional[Dict[str, list[Any]]] = None,
+ ) -> Iterator[tuple[str, AttributeValue]]:
+ """Extract attributes from agent span."""
+ yield GEN_AI_OPERATION_NAME, self._get_operation_name(span_data)
+
+ name = (
+ self.agent_name
+ or getattr(span_data, "name", None)
+ or self._agent_name_default
+ )
+ if name:
+ yield GEN_AI_AGENT_NAME, name
+
+ agent_id = (
+ self.agent_id
+ or getattr(span_data, "agent_id", None)
+ or self._agent_id_default
+ )
+ if agent_id:
+ yield GEN_AI_AGENT_ID, agent_id
+
+ description = (
+ self.agent_description
+ or getattr(span_data, "description", None)
+ or self._agent_description_default
+ )
+ if description:
+ yield GEN_AI_AGENT_DESCRIPTION, description
+
+ model = getattr(span_data, "model", None)
+ if not model and agent_content:
+ model = agent_content.get("request_model")
+ if model:
+ yield GEN_AI_REQUEST_MODEL, model
+
+ if hasattr(span_data, "conversation_id") and span_data.conversation_id:
+ yield GEN_AI_CONVERSATION_ID, span_data.conversation_id
+
+ # Agent definitions
+ if self._capture_tool_definitions and hasattr(
+ span_data, "agent_definitions"
+ ):
+ yield (
+ GEN_AI_ORCHESTRATOR_AGENT_DEFINITIONS,
+ safe_json_dumps(span_data.agent_definitions),
+ )
+
+ # System instructions from agent definitions
+ if self._capture_system_instructions and hasattr(
+ span_data, "agent_definitions"
+ ):
+ try:
+ defs = span_data.agent_definitions
+ if isinstance(defs, (list, tuple)):
+ collected: list[dict[str, str]] = []
+ for d in defs:
+ if isinstance(d, dict):
+ msgs = d.get("messages") or d.get(
+ "system_messages"
+ )
+ if isinstance(msgs, (list, tuple)):
+ collected.extend(
+ self._collect_system_instructions(msgs)
+ )
+ if collected:
+ yield (
+ GEN_AI_SYSTEM_INSTRUCTIONS,
+ safe_json_dumps(collected),
+ )
+ except Exception:
+ pass
+
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and self._capture_messages
+ and agent_content
+ ):
+ if agent_content.get("input_messages"):
+ yield (
+ GEN_AI_INPUT_MESSAGES,
+ safe_json_dumps(agent_content["input_messages"]),
+ )
+ if agent_content.get("output_messages"):
+ yield (
+ GEN_AI_OUTPUT_MESSAGES,
+ safe_json_dumps(agent_content["output_messages"]),
+ )
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and self._capture_system_instructions
+ and agent_content
+ and agent_content.get("system_instructions")
+ ):
+ yield (
+ GEN_AI_SYSTEM_INSTRUCTIONS,
+ safe_json_dumps(agent_content["system_instructions"]),
+ )
+
+ yield (
+ GEN_AI_OUTPUT_TYPE,
+ normalize_output_type(self._infer_output_type(span_data)),
+ )
+
+ def _get_attributes_from_function_span_data(
+ self, span_data: FunctionSpanData, payload: ContentPayload
+ ) -> Iterator[tuple[str, AttributeValue]]:
+ """Extract attributes from function/tool span."""
+ yield GEN_AI_OPERATION_NAME, GenAIOperationName.EXECUTE_TOOL
+
+ if span_data.name:
+ yield GEN_AI_TOOL_NAME, span_data.name
+
+ # Tool type - validate and normalize
+ tool_type = "function" # Default for function spans
+ if hasattr(span_data, "tool_type"):
+ tool_type = span_data.tool_type
+ yield GEN_AI_TOOL_TYPE, validate_tool_type(tool_type)
+
+ if hasattr(span_data, "call_id") and span_data.call_id:
+ yield GEN_AI_TOOL_CALL_ID, span_data.call_id
+ if hasattr(span_data, "description") and span_data.description:
+ yield GEN_AI_TOOL_DESCRIPTION, span_data.description
+
+ # Tool definitions
+ if self._capture_tool_definitions and hasattr(
+ span_data, "tool_definitions"
+ ):
+ yield (
+ GEN_AI_TOOL_DEFINITIONS,
+ safe_json_dumps(span_data.tool_definitions),
+ )
+
+ # Tool input/output (sensitive)
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and payload.tool_arguments is not None
+ ):
+ yield GEN_AI_TOOL_CALL_ARGUMENTS, payload.tool_arguments
+
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and payload.tool_result is not None
+ ):
+ yield GEN_AI_TOOL_CALL_RESULT, payload.tool_result
+
+ yield (
+ GEN_AI_OUTPUT_TYPE,
+ normalize_output_type(self._infer_output_type(span_data)),
+ )
+
+ def _get_attributes_from_response_span_data(
+ self, span_data: ResponseSpanData, payload: ContentPayload
+ ) -> Iterator[tuple[str, AttributeValue]]:
+ """Extract attributes from response span."""
+ yield GEN_AI_OPERATION_NAME, GenAIOperationName.CHAT
+
+ # Response information
+ if span_data.response:
+ if hasattr(span_data.response, "id") and span_data.response.id:
+ yield GEN_AI_RESPONSE_ID, span_data.response.id
+
+ # Model from response
+ if (
+ hasattr(span_data.response, "model")
+ and span_data.response.model
+ ):
+ yield GEN_AI_RESPONSE_MODEL, span_data.response.model
+ if not getattr(span_data, "model", None):
+ yield GEN_AI_REQUEST_MODEL, span_data.response.model
+
+ # Finish reasons
+ finish_reasons = []
+ if (
+ hasattr(span_data.response, "output")
+ and span_data.response.output
+ ):
+ for part in span_data.response.output:
+ if isinstance(part, dict):
+ fr = part.get("finish_reason") or part.get(
+ "stop_reason"
+ )
+ else:
+ fr = getattr(part, "finish_reason", None)
+ if fr:
+ finish_reasons.append(fr)
+ if finish_reasons:
+ yield GEN_AI_RESPONSE_FINISH_REASONS, finish_reasons
+
+ # Usage from response
+ if (
+ hasattr(span_data.response, "usage")
+ and span_data.response.usage
+ ):
+ usage = span_data.response.usage
+ self._sanitize_usage_payload(usage)
+ input_tokens = getattr(usage, "input_tokens", None)
+ if input_tokens is None:
+ input_tokens = getattr(usage, "prompt_tokens", None)
+ if input_tokens is not None:
+ yield GEN_AI_USAGE_INPUT_TOKENS, input_tokens
+
+ output_tokens = getattr(usage, "output_tokens", None)
+ if output_tokens is None:
+ output_tokens = getattr(usage, "completion_tokens", None)
+ if output_tokens is not None:
+ yield GEN_AI_USAGE_OUTPUT_TOKENS, output_tokens
+
+ # Input/output messages
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and self._capture_messages
+ and payload.input_messages
+ ):
+ yield (
+ GEN_AI_INPUT_MESSAGES,
+ safe_json_dumps(payload.input_messages),
+ )
+
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and self._capture_system_instructions
+ and payload.system_instructions
+ ):
+ yield (
+ GEN_AI_SYSTEM_INSTRUCTIONS,
+ safe_json_dumps(payload.system_instructions),
+ )
+
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and self._capture_messages
+ and payload.output_messages
+ ):
+ yield (
+ GEN_AI_OUTPUT_MESSAGES,
+ safe_json_dumps(payload.output_messages),
+ )
+
+ yield (
+ GEN_AI_OUTPUT_TYPE,
+ normalize_output_type(self._infer_output_type(span_data)),
+ )
+
+ def _get_attributes_from_transcription_span_data(
+ self, span_data: TranscriptionSpanData
+ ) -> Iterator[tuple[str, AttributeValue]]:
+ """Extract attributes from transcription span."""
+ yield GEN_AI_OPERATION_NAME, GenAIOperationName.TRANSCRIPTION
+
+ if hasattr(span_data, "model") and span_data.model:
+ yield GEN_AI_REQUEST_MODEL, span_data.model
+
+ # Audio format
+ if hasattr(span_data, "format") and span_data.format:
+ yield "gen_ai.audio.input.format", span_data.format
+
+ # Transcript (sensitive)
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and self._capture_messages
+ and hasattr(span_data, "transcript")
+ ):
+ yield "gen_ai.transcription.text", span_data.transcript
+
+ yield (
+ GEN_AI_OUTPUT_TYPE,
+ normalize_output_type(self._infer_output_type(span_data)),
+ )
+
+ def _get_attributes_from_speech_span_data(
+ self, span_data: SpeechSpanData
+ ) -> Iterator[tuple[str, AttributeValue]]:
+ """Extract attributes from speech span."""
+ yield GEN_AI_OPERATION_NAME, GenAIOperationName.SPEECH
+
+ if hasattr(span_data, "model") and span_data.model:
+ yield GEN_AI_REQUEST_MODEL, span_data.model
+
+ if hasattr(span_data, "voice") and span_data.voice:
+ yield "gen_ai.speech.voice", span_data.voice
+
+ if hasattr(span_data, "format") and span_data.format:
+ yield "gen_ai.audio.output.format", span_data.format
+
+ # Input text (sensitive)
+ if (
+ self.include_sensitive_data
+ and self._content_mode.capture_in_span
+ and self._capture_messages
+ and hasattr(span_data, "input_text")
+ ):
+ yield "gen_ai.speech.input_text", span_data.input_text
+
+ yield (
+ GEN_AI_OUTPUT_TYPE,
+ normalize_output_type(self._infer_output_type(span_data)),
+ )
+
+ def _get_attributes_from_guardrail_span_data(
+ self, span_data: GuardrailSpanData
+ ) -> Iterator[tuple[str, AttributeValue]]:
+ """Extract attributes from guardrail span."""
+ yield GEN_AI_OPERATION_NAME, GenAIOperationName.GUARDRAIL
+
+ if span_data.name:
+ yield GEN_AI_GUARDRAIL_NAME, span_data.name
+
+ yield GEN_AI_GUARDRAIL_TRIGGERED, span_data.triggered
+ yield (
+ GEN_AI_OUTPUT_TYPE,
+ normalize_output_type(self._infer_output_type(span_data)),
+ )
+
+ def _get_attributes_from_handoff_span_data(
+ self, span_data: HandoffSpanData
+ ) -> Iterator[tuple[str, AttributeValue]]:
+ """Extract attributes from handoff span."""
+ yield GEN_AI_OPERATION_NAME, GenAIOperationName.HANDOFF
+
+ if span_data.from_agent:
+ yield GEN_AI_HANDOFF_FROM_AGENT, span_data.from_agent
+
+ if span_data.to_agent:
+ yield GEN_AI_HANDOFF_TO_AGENT, span_data.to_agent
+
+ yield (
+ GEN_AI_OUTPUT_TYPE,
+ normalize_output_type(self._infer_output_type(span_data)),
+ )
+
+ def _cleanup_spans_for_trace(self, trace_id: str) -> None:
+ """Clean up spans for a trace to prevent memory leaks."""
+ spans_to_remove = [
+ span_id
+ for span_id in self._otel_spans.keys()
+ if span_id.startswith(trace_id)
+ ]
+ for span_id in spans_to_remove:
+ if otel_span := self._otel_spans.pop(span_id, None):
+ otel_span.set_status(
+ Status(
+ StatusCode.ERROR, "Trace ended before span completion"
+ )
+ )
+ otel_span.end()
+ self._tokens.pop(span_id, None)
+
+
+__all__ = [
+ "GenAIProvider",
+ "GenAIOperationName",
+ "GenAIToolType",
+ "GenAIOutputType",
+ "GenAIEvaluationAttributes",
+ "ContentCaptureMode",
+ "ContentPayload",
+ "GenAISemanticProcessor",
+ "normalize_provider",
+ "normalize_output_type",
+ "validate_tool_type",
+]
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/version.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/version.py
new file mode 100644
index 0000000..3e15120
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/src/opentelemetry/instrumentation/openai_agents/version.py
@@ -0,0 +1,15 @@
+# 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.
+
+__version__ = "0.2.0.dev"
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/conftest.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/conftest.py
new file mode 100644
index 0000000..61d8621
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/conftest.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+TESTS_ROOT = Path(__file__).resolve().parent
+GENAI_ROOT = TESTS_ROOT.parent
+REPO_ROOT = GENAI_ROOT.parent
+PROJECT_ROOT = REPO_ROOT.parent
+
+for path in (
+ PROJECT_ROOT / "opentelemetry-instrumentation" / "src",
+ GENAI_ROOT / "src",
+ PROJECT_ROOT / "util" / "opentelemetry-util-genai" / "src",
+ REPO_ROOT / "openai_agents_lib",
+ REPO_ROOT / "openai_lib",
+ TESTS_ROOT / "stubs",
+):
+ path_str = str(path)
+ if path_str not in sys.path:
+ sys.path.insert(0, path_str)
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.latest.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.latest.txt
new file mode 100644
index 0000000..e224e43
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.latest.txt
@@ -0,0 +1,53 @@
+# 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:
+#
+# openai_agents-latest: {[testenv]test_deps}
+# openai_agents-latest: -r {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.latest.txt
+#
+# This provides additional dependencies, namely:
+#
+# opentelemetry-api
+# opentelemetry-sdk
+# opentelemetry-semantic-conventions
+#
+# ... 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.
+
+openai-agents==0.3.3
+pydantic>=2.10,<3
+httpx==0.27.2
+Deprecated==1.2.14
+importlib-metadata==6.11.0
+packaging==24.0
+pytest==7.4.4
+pytest-asyncio==0.21.0
+wrapt==1.16.0
+# test with the latest version of opentelemetry-api, sdk, and semantic conventions
+grpcio>=1.60.0; implementation_name != "pypy"
+grpcio<1.60.0; implementation_name == "pypy"
+
+-e opentelemetry-instrumentation
+-e instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.oldest.txt
new file mode 100644
index 0000000..2e935a6
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/requirements.oldest.txt
@@ -0,0 +1,35 @@
+# 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.
+
+# This variant of the requirements aims to test the system using
+# the oldest supported version of external dependencies.
+
+openai-agents==0.3.3
+pydantic>=2.10,<3
+httpx==0.27.2
+Deprecated==1.2.14
+importlib-metadata==6.11.0
+packaging==24.0
+pytest==7.4.4
+pytest-asyncio==0.21.0
+wrapt==1.16.0
+opentelemetry-exporter-otlp-proto-grpc~=1.30
+opentelemetry-exporter-otlp-proto-http~=1.30
+opentelemetry-api==1.37 # when updating, also update in pyproject.toml
+opentelemetry-sdk==1.37 # when updating, also update in pyproject.toml
+opentelemetry-semantic-conventions==0.58b0 # when updating, also update in pyproject.toml
+grpcio>=1.60.0; implementation_name != "pypy"
+grpcio<1.60.0; implementation_name == "pypy"
+
+-e instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/__init__.py
new file mode 100644
index 0000000..7804f9c
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/__init__.py
@@ -0,0 +1 @@
+# Stub package for tests
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/__init__.py
new file mode 100644
index 0000000..4ed06c8
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/__init__.py
@@ -0,0 +1,232 @@
+# pylint: skip-file
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from dataclasses import dataclass
+from itertools import count
+from typing import Any, Mapping, Sequence
+
+from .processor_interface import TracingProcessor
+from .spans import Span
+from .traces import Trace
+
+SPAN_TYPE_AGENT = "agent"
+SPAN_TYPE_FUNCTION = "function"
+SPAN_TYPE_GENERATION = "generation"
+SPAN_TYPE_RESPONSE = "response"
+
+__all__ = [
+ "TraceProvider",
+ "get_trace_provider",
+ "set_trace_processors",
+ "trace",
+ "agent_span",
+ "generation_span",
+ "function_span",
+ "response_span",
+ "AgentSpanData",
+ "GenerationSpanData",
+ "FunctionSpanData",
+ "ResponseSpanData",
+]
+
+
+@dataclass
+class AgentSpanData:
+ name: str | None = None
+ tools: list[str] | None = None
+ output_type: str | None = None
+ description: str | None = None
+ agent_id: str | None = None
+ model: str | None = None
+ operation: str | None = None
+
+ @property
+ def type(self) -> str:
+ return SPAN_TYPE_AGENT
+
+
+@dataclass
+class FunctionSpanData:
+ name: str | None = None
+ input: Any = None
+ output: Any = None
+
+ @property
+ def type(self) -> str:
+ return SPAN_TYPE_FUNCTION
+
+
+@dataclass
+class GenerationSpanData:
+ input: Sequence[Mapping[str, Any]] | None = None
+ output: Sequence[Mapping[str, Any]] | None = None
+ model: str | None = None
+ model_config: Mapping[str, Any] | None = None
+ usage: Mapping[str, Any] | None = None
+
+ @property
+ def type(self) -> str:
+ return SPAN_TYPE_GENERATION
+
+
+@dataclass
+class ResponseSpanData:
+ response: Any = None
+
+ @property
+ def type(self) -> str:
+ return SPAN_TYPE_RESPONSE
+
+
+class _ProcessorFanout(TracingProcessor):
+ def __init__(self) -> None:
+ self._processors: list[TracingProcessor] = []
+
+ def add_tracing_processor(self, processor: TracingProcessor) -> None:
+ self._processors.append(processor)
+
+ def set_processors(self, processors: list[TracingProcessor]) -> None:
+ self._processors = list(processors)
+
+ def on_trace_start(self, trace: Trace) -> None:
+ for processor in list(self._processors):
+ processor.on_trace_start(trace)
+
+ def on_trace_end(self, trace: Trace) -> None:
+ for processor in list(self._processors):
+ processor.on_trace_end(trace)
+
+ def on_span_start(self, span: Span) -> None:
+ for processor in list(self._processors):
+ processor.on_span_start(span)
+
+ def on_span_end(self, span: Span) -> None:
+ for processor in list(self._processors):
+ processor.on_span_end(span)
+
+ def shutdown(self) -> None:
+ for processor in list(self._processors):
+ processor.shutdown()
+
+ def force_flush(self) -> None:
+ for processor in list(self._processors):
+ processor.force_flush()
+
+
+class TraceProvider:
+ def __init__(self) -> None:
+ self._multi_processor = _ProcessorFanout()
+ self._ids = count(1)
+
+ def register_processor(self, processor: TracingProcessor) -> None:
+ self._multi_processor.add_tracing_processor(processor)
+
+ def set_processors(self, processors: list[TracingProcessor]) -> None:
+ self._multi_processor.set_processors(processors)
+
+ def create_trace(
+ self,
+ name: str,
+ trace_id: str | None = None,
+ group_id: str | None = None,
+ metadata: Mapping[str, Any] | None = None,
+ disabled: bool = False,
+ ) -> Trace:
+ trace_id = trace_id or f"trace_{next(self._ids)}"
+ return Trace(name, trace_id, self._multi_processor)
+
+ def create_span(
+ self,
+ span_data: Any,
+ span_id: str | None = None,
+ parent: Trace | Span | None = None,
+ disabled: bool = False,
+ ) -> Span:
+ span_id = span_id or f"span_{next(self._ids)}"
+ if isinstance(parent, Span):
+ trace_id = parent.trace_id
+ parent_id = parent.span_id
+ elif isinstance(parent, Trace):
+ trace_id = parent.trace_id
+ parent_id = None
+ else:
+ trace_id = f"trace_{next(self._ids)}"
+ parent_id = None
+ return Span(
+ trace_id, span_id, span_data, parent_id, self._multi_processor
+ )
+
+ def shutdown(self) -> None:
+ self._multi_processor.shutdown()
+
+
+_PROVIDER = TraceProvider()
+_CURRENT_TRACE: Trace | None = None
+
+
+def get_trace_provider() -> TraceProvider:
+ return _PROVIDER
+
+
+def set_trace_processors(processors: list[TracingProcessor]) -> None:
+ _PROVIDER.set_processors(processors)
+
+
+@contextmanager
+def trace(name: str, **kwargs: Any):
+ global _CURRENT_TRACE
+ trace_obj = _PROVIDER.create_trace(name, **kwargs)
+ previous = _CURRENT_TRACE
+ _CURRENT_TRACE = trace_obj
+ trace_obj.start()
+ try:
+ yield trace_obj
+ finally:
+ trace_obj.finish()
+ _CURRENT_TRACE = previous
+
+
+@contextmanager
+def generation_span(**kwargs: Any):
+ data = GenerationSpanData(**kwargs)
+ span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE)
+ span.start()
+ try:
+ yield span
+ finally:
+ span.finish()
+
+
+@contextmanager
+def agent_span(**kwargs: Any):
+ data = AgentSpanData(**kwargs)
+ span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE)
+ span.start()
+ try:
+ yield span
+ finally:
+ span.finish()
+
+
+@contextmanager
+def function_span(**kwargs: Any):
+ data = FunctionSpanData(**kwargs)
+ span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE)
+ span.start()
+ try:
+ yield span
+ finally:
+ span.finish()
+
+
+@contextmanager
+def response_span(**kwargs: Any):
+ data = ResponseSpanData(**kwargs)
+ span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE)
+ span.start()
+ try:
+ yield span
+ finally:
+ span.finish()
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/processor_interface.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/processor_interface.py
new file mode 100644
index 0000000..4645555
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/processor_interface.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+
+class TracingProcessor:
+ def on_trace_start(self, trace): # pragma: no cover - stub
+ pass
+
+ def on_trace_end(self, trace): # pragma: no cover - stub
+ pass
+
+ def on_span_start(self, span): # pragma: no cover - stub
+ pass
+
+ def on_span_end(self, span): # pragma: no cover - stub
+ pass
+
+ def shutdown(self) -> None: # pragma: no cover - stub
+ pass
+
+ def force_flush(self) -> None: # pragma: no cover - stub
+ pass
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/spans.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/spans.py
new file mode 100644
index 0000000..5b7335a
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/spans.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any
+
+
+class Span:
+ def __init__(
+ self,
+ trace_id: str,
+ span_id: str,
+ span_data: Any,
+ parent_id: str | None,
+ processor,
+ ) -> None:
+ self.trace_id = trace_id
+ self.span_id = span_id
+ self.span_data = span_data
+ self.parent_id = parent_id
+ self.started_at: str | None = None
+ self.ended_at: str | None = None
+ self.error = None
+ self._processor = processor
+
+ def start(self) -> None:
+ if self.started_at is not None:
+ return
+ self.started_at = datetime.utcnow().isoformat() + "Z"
+ self._processor.on_span_start(self)
+
+ def finish(self) -> None:
+ if self.ended_at is not None:
+ return
+ self.ended_at = datetime.utcnow().isoformat() + "Z"
+ self._processor.on_span_end(self)
+
+ def __enter__(self) -> "Span":
+ self.start()
+ return self
+
+ def __exit__(self, exc_type, exc, tb) -> None:
+ self.finish()
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/traces.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/traces.py
new file mode 100644
index 0000000..895c0e3
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/agents/tracing/traces.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+
+class Trace:
+ def __init__(self, name: str, trace_id: str, processor) -> None:
+ self.name = name
+ self.trace_id = trace_id
+ self._processor = processor
+ self.started_at: str | None = None
+ self.ended_at: str | None = None
+
+ def start(self) -> None:
+ if self.started_at is not None:
+ return
+ self.started_at = datetime.utcnow().isoformat() + "Z"
+ self._processor.on_trace_start(self)
+
+ def finish(self) -> None:
+ if self.ended_at is not None:
+ return
+ self.ended_at = datetime.utcnow().isoformat() + "Z"
+ self._processor.on_trace_end(self)
+
+ def __enter__(self) -> "Trace":
+ self.start()
+ return self
+
+ def __exit__(self, exc_type, exc, tb) -> None:
+ self.finish()
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/opentelemetry/instrumentation/instrumentor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/opentelemetry/instrumentation/instrumentor.py
new file mode 100644
index 0000000..a84ac49
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/opentelemetry/instrumentation/instrumentor.py
@@ -0,0 +1,28 @@
+# pylint: skip-file
+
+from __future__ import annotations
+
+from typing import Collection
+
+
+class BaseInstrumentor:
+ def __init__(self) -> None:
+ pass
+
+ def instrument(self, **kwargs) -> None:
+ self._instrument(**kwargs)
+
+ def uninstrument(self, **kwargs) -> None:
+ self._uninstrument(**kwargs)
+
+ # Subclasses override
+ def _instrument(self, **kwargs) -> None: # pragma: no cover - stub
+ raise NotImplementedError
+
+ def _uninstrument(self, **kwargs) -> None: # pragma: no cover - stub
+ raise NotImplementedError
+
+ def instrumentation_dependencies(
+ self,
+ ) -> Collection[str]: # pragma: no cover
+ return []
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/wrapt.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/wrapt.py
new file mode 100644
index 0000000..ba3b8ab
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/stubs/wrapt.py
@@ -0,0 +1,17 @@
+class ObjectProxy:
+ """Minimal stand-in for wrapt.ObjectProxy used in tests."""
+
+ def __init__(self, wrapped):
+ self.__wrapped__ = wrapped
+
+ def __getattr__(self, item):
+ return getattr(self.__wrapped__, item)
+
+ def __setattr__(self, key, value):
+ if key == "__wrapped__":
+ super().__setattr__(key, value)
+ else:
+ setattr(self.__wrapped__, key, value)
+
+ def __call__(self, *args, **kwargs):
+ return self.__wrapped__(*args, **kwargs)
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_tracer.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_tracer.py
new file mode 100644
index 0000000..1f21ab2
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_tracer.py
@@ -0,0 +1,521 @@
+# pylint: disable=wrong-import-position,wrong-import-order,import-error,no-name-in-module,unexpected-keyword-arg,no-value-for-parameter,redefined-outer-name
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+TESTS_ROOT = Path(__file__).resolve().parent
+stub_path = TESTS_ROOT / "stubs"
+if str(stub_path) not in sys.path:
+ sys.path.insert(0, str(stub_path))
+
+sys.modules.pop("agents", None)
+sys.modules.pop("agents.tracing", None)
+
+import agents.tracing as agents_tracing # noqa: E402
+from agents.tracing import ( # noqa: E402
+ agent_span,
+ function_span,
+ generation_span,
+ response_span,
+ set_trace_processors,
+ trace,
+)
+
+from opentelemetry.instrumentation.openai_agents import ( # noqa: E402
+ OpenAIAgentsInstrumentor,
+)
+from opentelemetry.instrumentation.openai_agents.span_processor import ( # noqa: E402
+ ContentPayload,
+ GenAISemanticProcessor,
+)
+from opentelemetry.sdk.trace import TracerProvider # noqa: E402
+
+try:
+ from opentelemetry.sdk.trace.export import ( # type: ignore[attr-defined]
+ InMemorySpanExporter,
+ SimpleSpanProcessor,
+ )
+except ImportError: # pragma: no cover - support older/newer SDK layouts
+ from opentelemetry.sdk.trace.export import (
+ SimpleSpanProcessor, # noqa: E402
+ )
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402
+ InMemorySpanExporter,
+ )
+from opentelemetry.semconv._incubating.attributes import ( # noqa: E402
+ gen_ai_attributes as GenAI,
+)
+from opentelemetry.semconv._incubating.attributes import ( # noqa: E402
+ server_attributes as ServerAttributes,
+)
+from opentelemetry.trace import SpanKind # noqa: E402
+
+GEN_AI_PROVIDER_NAME = GenAI.GEN_AI_PROVIDER_NAME
+GEN_AI_INPUT_MESSAGES = getattr(
+ GenAI, "GEN_AI_INPUT_MESSAGES", "gen_ai.input.messages"
+)
+GEN_AI_OUTPUT_MESSAGES = getattr(
+ GenAI, "GEN_AI_OUTPUT_MESSAGES", "gen_ai.output.messages"
+)
+
+
+def _instrument_with_provider(**instrument_kwargs):
+ set_trace_processors([])
+ provider = TracerProvider()
+ exporter = InMemorySpanExporter()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+
+ instrumentor = OpenAIAgentsInstrumentor()
+ instrumentor.instrument(tracer_provider=provider, **instrument_kwargs)
+
+ return instrumentor, exporter
+
+
+def test_generation_span_creates_client_span():
+ instrumentor, exporter = _instrument_with_provider()
+
+ try:
+ with trace("workflow"):
+ with generation_span(
+ input=[{"role": "user", "content": "hi"}],
+ model="gpt-4o-mini",
+ model_config={
+ "temperature": 0.2,
+ "base_url": "https://api.openai.com",
+ },
+ usage={"input_tokens": 12, "output_tokens": 3},
+ ):
+ pass
+
+ spans = exporter.get_finished_spans()
+ client_span = next(
+ span for span in spans if span.kind is SpanKind.CLIENT
+ )
+
+ assert client_span.attributes[GEN_AI_PROVIDER_NAME] == "openai"
+ assert client_span.attributes[GenAI.GEN_AI_OPERATION_NAME] == "chat"
+ assert (
+ client_span.attributes[GenAI.GEN_AI_REQUEST_MODEL] == "gpt-4o-mini"
+ )
+ assert client_span.name == "chat gpt-4o-mini"
+ assert client_span.attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] == 12
+ assert client_span.attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] == 3
+ assert (
+ client_span.attributes[ServerAttributes.SERVER_ADDRESS]
+ == "api.openai.com"
+ )
+ finally:
+ instrumentor.uninstrument()
+ exporter.clear()
+
+
+def test_generation_span_without_roles_uses_text_completion():
+ instrumentor, exporter = _instrument_with_provider()
+
+ try:
+ with trace("workflow"):
+ with generation_span(
+ input=[{"content": "tell me a joke"}],
+ model="gpt-4o-mini",
+ model_config={"temperature": 0.7},
+ ):
+ pass
+
+ spans = exporter.get_finished_spans()
+ completion_span = next(
+ span
+ for span in spans
+ if span.attributes[GenAI.GEN_AI_OPERATION_NAME]
+ == GenAI.GenAiOperationNameValues.TEXT_COMPLETION.value
+ )
+
+ assert completion_span.kind is SpanKind.CLIENT
+ assert completion_span.name == "text_completion gpt-4o-mini"
+ assert (
+ completion_span.attributes[GenAI.GEN_AI_REQUEST_MODEL]
+ == "gpt-4o-mini"
+ )
+ finally:
+ instrumentor.uninstrument()
+ exporter.clear()
+
+
+def test_function_span_records_tool_attributes():
+ instrumentor, exporter = _instrument_with_provider()
+
+ try:
+ with trace("workflow"):
+ with function_span(
+ name="fetch_weather", input='{"city": "Paris"}'
+ ):
+ pass
+
+ spans = exporter.get_finished_spans()
+ tool_span = next(
+ span for span in spans if span.kind is SpanKind.INTERNAL
+ )
+
+ assert (
+ tool_span.attributes[GenAI.GEN_AI_OPERATION_NAME] == "execute_tool"
+ )
+ assert tool_span.attributes[GenAI.GEN_AI_TOOL_NAME] == "fetch_weather"
+ assert tool_span.attributes[GenAI.GEN_AI_TOOL_TYPE] == "function"
+ assert tool_span.attributes[GEN_AI_PROVIDER_NAME] == "openai"
+ finally:
+ instrumentor.uninstrument()
+ exporter.clear()
+
+
+def test_agent_create_span_records_attributes():
+ instrumentor, exporter = _instrument_with_provider()
+
+ try:
+ with trace("workflow"):
+ with agent_span(
+ operation="create",
+ name="support_bot",
+ description="Answers support questions",
+ agent_id="agt_123",
+ model="gpt-4o-mini",
+ ):
+ pass
+
+ spans = exporter.get_finished_spans()
+ create_span = next(
+ span
+ for span in spans
+ if span.attributes[GenAI.GEN_AI_OPERATION_NAME]
+ == GenAI.GenAiOperationNameValues.CREATE_AGENT.value
+ )
+
+ assert create_span.kind is SpanKind.CLIENT
+ assert create_span.name == "create_agent support_bot"
+ assert create_span.attributes[GEN_AI_PROVIDER_NAME] == "openai"
+ assert create_span.attributes[GenAI.GEN_AI_AGENT_NAME] == "support_bot"
+ assert (
+ create_span.attributes[GenAI.GEN_AI_AGENT_DESCRIPTION]
+ == "Answers support questions"
+ )
+ assert create_span.attributes[GenAI.GEN_AI_AGENT_ID] == "agt_123"
+ assert (
+ create_span.attributes[GenAI.GEN_AI_REQUEST_MODEL] == "gpt-4o-mini"
+ )
+ finally:
+ instrumentor.uninstrument()
+ exporter.clear()
+
+
+def _placeholder_message() -> dict[str, Any]:
+ return {
+ "role": "user",
+ "parts": [{"type": "text", "content": "readacted"}],
+ }
+
+
+def test_normalize_messages_skips_empty_when_sensitive_enabled():
+ processor = GenAISemanticProcessor(metrics_enabled=False)
+ normalized = processor._normalize_messages_to_role_parts(
+ [{"role": "user", "content": None}]
+ )
+ assert not normalized
+
+
+def test_normalize_messages_emits_placeholder_when_sensitive_disabled():
+ processor = GenAISemanticProcessor(
+ include_sensitive_data=False, metrics_enabled=False
+ )
+ normalized = processor._normalize_messages_to_role_parts(
+ [{"role": "user", "content": None}]
+ )
+ assert normalized == [_placeholder_message()]
+
+
+def test_agent_content_aggregation_skips_duplicate_snapshots():
+ processor = GenAISemanticProcessor(metrics_enabled=False)
+ agent_id = "agent-span"
+ processor._agent_content[agent_id] = {
+ "input_messages": [],
+ "output_messages": [],
+ "system_instructions": [],
+ }
+
+ payload = ContentPayload(
+ input_messages=[
+ {"role": "user", "parts": [{"type": "text", "content": "hello"}]},
+ {
+ "role": "user",
+ "parts": [{"type": "text", "content": "readacted"}],
+ },
+ ]
+ )
+
+ processor._update_agent_aggregate(
+ SimpleNamespace(span_id="child-1", parent_id=agent_id, span_data=None),
+ payload,
+ )
+ processor._update_agent_aggregate(
+ SimpleNamespace(span_id="child-2", parent_id=agent_id, span_data=None),
+ payload,
+ )
+
+ aggregated = processor._agent_content[agent_id]["input_messages"]
+ assert aggregated == [
+ {"role": "user", "parts": [{"type": "text", "content": "hello"}]}
+ ]
+ # ensure data copied rather than reused to prevent accidental mutation
+ assert aggregated is not payload.input_messages
+
+
+def test_agent_content_aggregation_filters_placeholder_append_when_sensitive():
+ processor = GenAISemanticProcessor(metrics_enabled=False)
+ agent_id = "agent-span"
+ processor._agent_content[agent_id] = {
+ "input_messages": [],
+ "output_messages": [],
+ "system_instructions": [],
+ }
+
+ initial_payload = ContentPayload(
+ input_messages=[
+ {"role": "user", "parts": [{"type": "text", "content": "hello"}]}
+ ]
+ )
+ processor._update_agent_aggregate(
+ SimpleNamespace(span_id="child-1", parent_id=agent_id, span_data=None),
+ initial_payload,
+ )
+
+ placeholder_payload = ContentPayload(
+ input_messages=[_placeholder_message()]
+ )
+ processor._update_agent_aggregate(
+ SimpleNamespace(span_id="child-2", parent_id=agent_id, span_data=None),
+ placeholder_payload,
+ )
+
+ aggregated = processor._agent_content[agent_id]["input_messages"]
+ assert aggregated == [
+ {"role": "user", "parts": [{"type": "text", "content": "hello"}]}
+ ]
+
+
+def test_agent_content_aggregation_retains_placeholder_when_sensitive_disabled():
+ processor = GenAISemanticProcessor(
+ include_sensitive_data=False, metrics_enabled=False
+ )
+ agent_id = "agent-span"
+ processor._agent_content[agent_id] = {
+ "input_messages": [],
+ "output_messages": [],
+ "system_instructions": [],
+ }
+
+ placeholder_payload = ContentPayload(
+ input_messages=[_placeholder_message()]
+ )
+ processor._update_agent_aggregate(
+ SimpleNamespace(span_id="child-1", parent_id=agent_id, span_data=None),
+ placeholder_payload,
+ )
+
+ aggregated = processor._agent_content[agent_id]["input_messages"]
+ assert aggregated == [_placeholder_message()]
+
+
+def test_agent_content_aggregation_appends_new_messages_once():
+ processor = GenAISemanticProcessor(metrics_enabled=False)
+ agent_id = "agent-span"
+ processor._agent_content[agent_id] = {
+ "input_messages": [],
+ "output_messages": [],
+ "system_instructions": [],
+ }
+
+ initial_payload = ContentPayload(
+ input_messages=[
+ {"role": "user", "parts": [{"type": "text", "content": "hello"}]}
+ ]
+ )
+ processor._update_agent_aggregate(
+ SimpleNamespace(span_id="child-1", parent_id=agent_id, span_data=None),
+ initial_payload,
+ )
+
+ extended_messages = [
+ {"role": "user", "parts": [{"type": "text", "content": "hello"}]},
+ {
+ "role": "assistant",
+ "parts": [{"type": "text", "content": "hi there"}],
+ },
+ ]
+ extended_payload = ContentPayload(input_messages=extended_messages)
+ processor._update_agent_aggregate(
+ SimpleNamespace(span_id="child-2", parent_id=agent_id, span_data=None),
+ extended_payload,
+ )
+
+ aggregated = processor._agent_content[agent_id]["input_messages"]
+ assert aggregated == extended_messages
+ assert extended_payload.input_messages == extended_messages
+
+
+def test_agent_span_collects_child_messages():
+ instrumentor, exporter = _instrument_with_provider()
+
+ try:
+ provider = agents_tracing.get_trace_provider()
+
+ with trace("workflow") as workflow:
+ agent_span_obj = provider.create_span(
+ agents_tracing.AgentSpanData(name="helper"),
+ parent=workflow,
+ )
+ agent_span_obj.start()
+
+ generation = agents_tracing.GenerationSpanData(
+ input=[{"role": "user", "content": "hi"}],
+ output=[{"type": "text", "content": "hello"}],
+ model="gpt-4o-mini",
+ )
+ gen_span = provider.create_span(generation, parent=agent_span_obj)
+ gen_span.start()
+ gen_span.finish()
+
+ agent_span_obj.finish()
+
+ spans = exporter.get_finished_spans()
+ agent_span = next(
+ span
+ for span in spans
+ if span.attributes.get(GenAI.GEN_AI_OPERATION_NAME)
+ == GenAI.GenAiOperationNameValues.INVOKE_AGENT.value
+ )
+
+ prompt = json.loads(agent_span.attributes[GEN_AI_INPUT_MESSAGES])
+ completion = json.loads(agent_span.attributes[GEN_AI_OUTPUT_MESSAGES])
+
+ assert prompt == [
+ {
+ "role": "user",
+ "parts": [{"type": "text", "content": "hi"}],
+ }
+ ]
+ assert completion == [
+ {
+ "role": "assistant",
+ "parts": [{"type": "text", "content": "hello"}],
+ }
+ ]
+
+ assert not agent_span.events
+ finally:
+ instrumentor.uninstrument()
+ exporter.clear()
+
+
+def test_agent_name_override_applied_to_agent_spans():
+ instrumentor, exporter = _instrument_with_provider(
+ agent_name="Travel Concierge"
+ )
+
+ try:
+ with trace("workflow"):
+ with agent_span(operation="invoke", name="support_bot"):
+ pass
+
+ spans = exporter.get_finished_spans()
+ agent_span_record = next(
+ span
+ for span in spans
+ if span.attributes[GenAI.GEN_AI_OPERATION_NAME]
+ == GenAI.GenAiOperationNameValues.INVOKE_AGENT.value
+ )
+
+ assert agent_span_record.kind is SpanKind.CLIENT
+ assert agent_span_record.name == "invoke_agent Travel Concierge"
+ assert (
+ agent_span_record.attributes[GenAI.GEN_AI_AGENT_NAME]
+ == "Travel Concierge"
+ )
+ finally:
+ instrumentor.uninstrument()
+ exporter.clear()
+
+
+def test_capture_mode_can_be_disabled():
+ instrumentor, exporter = _instrument_with_provider(
+ capture_message_content="no_content"
+ )
+
+ try:
+ with trace("workflow"):
+ with generation_span(
+ input=[{"role": "user", "content": "hi"}],
+ output=[{"role": "assistant", "content": "hello"}],
+ model="gpt-4o-mini",
+ ):
+ pass
+
+ spans = exporter.get_finished_spans()
+ client_span = next(
+ span for span in spans if span.kind is SpanKind.CLIENT
+ )
+
+ assert GEN_AI_INPUT_MESSAGES not in client_span.attributes
+ assert GEN_AI_OUTPUT_MESSAGES not in client_span.attributes
+ for event in client_span.events:
+ assert GEN_AI_INPUT_MESSAGES not in event.attributes
+ assert GEN_AI_OUTPUT_MESSAGES not in event.attributes
+ finally:
+ instrumentor.uninstrument()
+ exporter.clear()
+
+
+def test_response_span_records_response_attributes():
+ instrumentor, exporter = _instrument_with_provider()
+
+ class _Usage:
+ def __init__(self, input_tokens: int, output_tokens: int) -> None:
+ self.input_tokens = input_tokens
+ self.output_tokens = output_tokens
+
+ class _Response:
+ def __init__(self) -> None:
+ self.id = "resp-123"
+ self.model = "gpt-4o-mini"
+ self.usage = _Usage(42, 9)
+ self.output = [{"finish_reason": "stop"}]
+
+ try:
+ with trace("workflow"):
+ with response_span(response=_Response()):
+ pass
+
+ spans = exporter.get_finished_spans()
+ response = next(
+ span
+ for span in spans
+ if span.attributes[GenAI.GEN_AI_OPERATION_NAME]
+ == GenAI.GenAiOperationNameValues.CHAT.value
+ )
+
+ assert response.kind is SpanKind.CLIENT
+ assert response.name == "chat gpt-4o-mini"
+ assert response.attributes[GEN_AI_PROVIDER_NAME] == "openai"
+ assert response.attributes[GenAI.GEN_AI_RESPONSE_ID] == "resp-123"
+ assert (
+ response.attributes[GenAI.GEN_AI_RESPONSE_MODEL] == "gpt-4o-mini"
+ )
+ assert response.attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] == 42
+ assert response.attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] == 9
+ assert response.attributes[GenAI.GEN_AI_RESPONSE_FINISH_REASONS] == (
+ "stop",
+ )
+ finally:
+ instrumentor.uninstrument()
+ exporter.clear()
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_instrumentor_behaviors.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_instrumentor_behaviors.py
new file mode 100644
index 0000000..e63e7b2
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_instrumentor_behaviors.py
@@ -0,0 +1,69 @@
+# pylint: disable=wrong-import-position,wrong-import-order,import-error
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+TESTS_ROOT = Path(__file__).resolve().parent
+stub_path = TESTS_ROOT / "stubs"
+if str(stub_path) not in sys.path:
+ sys.path.insert(0, str(stub_path))
+
+from agents.tracing import ( # noqa: E402
+ get_trace_provider,
+ set_trace_processors,
+)
+
+from opentelemetry.instrumentation.openai_agents import ( # noqa: E402
+ OpenAIAgentsInstrumentor,
+)
+from opentelemetry.instrumentation.openai_agents.package import ( # noqa: E402
+ _instruments,
+)
+from opentelemetry.sdk.trace import TracerProvider # noqa: E402
+
+
+def test_double_instrument_is_noop():
+ set_trace_processors([])
+ provider = TracerProvider()
+ instrumentor = OpenAIAgentsInstrumentor()
+
+ instrumentor.instrument(tracer_provider=provider)
+ trace_provider = get_trace_provider()
+ assert len(trace_provider._multi_processor._processors) == 1
+
+ instrumentor.instrument(tracer_provider=provider)
+ assert len(trace_provider._multi_processor._processors) == 1
+
+ instrumentor.uninstrument()
+ instrumentor.uninstrument()
+ set_trace_processors([])
+
+
+def test_instrumentation_dependencies_exposed():
+ instrumentor = OpenAIAgentsInstrumentor()
+ assert instrumentor.instrumentation_dependencies() == _instruments
+
+
+def test_default_agent_configuration():
+ set_trace_processors([])
+ provider = TracerProvider()
+ instrumentor = OpenAIAgentsInstrumentor()
+
+ try:
+ instrumentor.instrument(tracer_provider=provider)
+ processor = instrumentor._processor
+ assert processor is not None
+ assert getattr(processor, "_agent_name_default") == "OpenAI Agent"
+ assert getattr(processor, "_agent_id_default") == "agent"
+ assert (
+ getattr(processor, "_agent_description_default")
+ == "OpenAI Agents instrumentation"
+ )
+ assert processor.base_url == "https://api.openai.com"
+ assert processor.server_address == "api.openai.com"
+ assert processor.server_port == 443
+ finally:
+ instrumentor.uninstrument()
+ set_trace_processors([])
diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_span_processor_unit.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_span_processor_unit.py
new file mode 100644
index 0000000..a827e65
--- /dev/null
+++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents-v2/tests/test_z_span_processor_unit.py
@@ -0,0 +1,664 @@
+# pylint: disable=wrong-import-position,wrong-import-order,import-error,no-name-in-module,unexpected-keyword-arg,no-value-for-parameter,redefined-outer-name,too-many-locals,too-many-statements,too-many-branches
+
+from __future__ import annotations
+
+import importlib
+import json
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from enum import Enum
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+from agents.tracing import (
+ AgentSpanData,
+ FunctionSpanData,
+ GenerationSpanData,
+ ResponseSpanData,
+)
+
+import opentelemetry.semconv._incubating.attributes.gen_ai_attributes as _gen_ai_attributes
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.semconv._incubating.attributes import (
+ server_attributes as _server_attributes,
+)
+from opentelemetry.trace import SpanKind
+from opentelemetry.trace.status import StatusCode
+
+
+def _ensure_semconv_enums() -> None:
+ if not hasattr(_gen_ai_attributes, "GenAiProviderNameValues"):
+
+ class _GenAiProviderNameValues(Enum):
+ OPENAI = "openai"
+ GCP_GEN_AI = "gcp.gen_ai"
+ GCP_VERTEX_AI = "gcp.vertex_ai"
+ GCP_GEMINI = "gcp.gemini"
+ ANTHROPIC = "anthropic"
+ COHERE = "cohere"
+ AZURE_AI_INFERENCE = "azure.ai.inference"
+ AZURE_AI_OPENAI = "azure.ai.openai"
+ IBM_WATSONX_AI = "ibm.watsonx.ai"
+ AWS_BEDROCK = "aws.bedrock"
+ PERPLEXITY = "perplexity"
+ X_AI = "x.ai"
+ DEEPSEEK = "deepseek"
+ GROQ = "groq"
+ MISTRAL_AI = "mistral.ai"
+
+ class _GenAiOperationNameValues(Enum):
+ CHAT = "chat"
+ GENERATE_CONTENT = "generate_content"
+ TEXT_COMPLETION = "text_completion"
+ EMBEDDINGS = "embeddings"
+ CREATE_AGENT = "create_agent"
+ INVOKE_AGENT = "invoke_agent"
+ EXECUTE_TOOL = "execute_tool"
+
+ class _GenAiOutputTypeValues(Enum):
+ TEXT = "text"
+ JSON = "json"
+ IMAGE = "image"
+ SPEECH = "speech"
+
+ _gen_ai_attributes.GenAiProviderNameValues = _GenAiProviderNameValues
+ _gen_ai_attributes.GenAiOperationNameValues = _GenAiOperationNameValues
+ _gen_ai_attributes.GenAiOutputTypeValues = _GenAiOutputTypeValues
+
+ if not hasattr(_server_attributes, "SERVER_ADDRESS"):
+ _server_attributes.SERVER_ADDRESS = "server.address"
+ if not hasattr(_server_attributes, "SERVER_PORT"):
+ _server_attributes.SERVER_PORT = "server.port"
+
+
+_ensure_semconv_enums()
+
+ServerAttributes = _server_attributes
+
+sp = importlib.import_module(
+ "opentelemetry.instrumentation.openai_agents.span_processor"
+)
+
+try:
+ from opentelemetry.sdk.trace.export import ( # type: ignore[attr-defined]
+ InMemorySpanExporter,
+ SimpleSpanProcessor,
+ )
+except ImportError: # pragma: no cover
+ from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+ from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
+ InMemorySpanExporter,
+ )
+
+
+def _collect(iterator) -> dict[str, Any]:
+ return dict(iterator)
+
+
+@pytest.fixture
+def processor_setup():
+ provider = TracerProvider()
+ exporter = InMemorySpanExporter()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = provider.get_tracer(__name__)
+ processor = sp.GenAISemanticProcessor(tracer=tracer, system_name="openai")
+ yield processor, exporter
+ processor.shutdown()
+ exporter.clear()
+
+
+def test_time_helpers():
+ dt = datetime(2024, 1, 1, tzinfo=timezone.utc)
+ assert sp._as_utc_nano(dt) == 1704067200 * 1_000_000_000
+
+ class Fallback:
+ def __str__(self) -> str:
+ return "fallback"
+
+ # Accept any JSON formatting as long as it round-trips correctly.
+ assert json.loads(sp.safe_json_dumps({"foo": "bar"})) == {"foo": "bar"}
+ assert sp.safe_json_dumps(Fallback()) == "fallback"
+
+
+def test_infer_server_attributes_variants(monkeypatch):
+ assert sp._infer_server_attributes(None) == {}
+ assert sp._infer_server_attributes(123) == {}
+
+ attrs = sp._infer_server_attributes("https://api.example.com:8080/v1")
+ assert attrs[ServerAttributes.SERVER_ADDRESS] == "api.example.com"
+ assert attrs[ServerAttributes.SERVER_PORT] == 8080
+
+ def boom(_: str):
+ raise ValueError("unparsable url")
+
+ monkeypatch.setattr(sp, "urlparse", boom)
+ assert sp._infer_server_attributes("bad") == {}
+
+
+def test_operation_and_span_naming(processor_setup):
+ processor, _ = processor_setup
+
+ generation = GenerationSpanData(input=[{"role": "user"}], model="gpt-4o")
+ assert (
+ processor._get_operation_name(generation) == sp.GenAIOperationName.CHAT
+ )
+
+ completion = GenerationSpanData(input=[])
+ assert (
+ processor._get_operation_name(completion)
+ == sp.GenAIOperationName.TEXT_COMPLETION
+ )
+
+ embeddings = GenerationSpanData(input=None)
+ setattr(embeddings, "embedding_dimension", 128)
+ assert (
+ processor._get_operation_name(embeddings)
+ == sp.GenAIOperationName.EMBEDDINGS
+ )
+
+ agent_create = AgentSpanData(operation=" CREATE ")
+ assert (
+ processor._get_operation_name(agent_create)
+ == sp.GenAIOperationName.CREATE_AGENT
+ )
+
+ agent_invoke = AgentSpanData(operation="invoke_agent")
+ assert (
+ processor._get_operation_name(agent_invoke)
+ == sp.GenAIOperationName.INVOKE_AGENT
+ )
+
+ agent_default = AgentSpanData(operation=None)
+ assert (
+ processor._get_operation_name(agent_default)
+ == sp.GenAIOperationName.INVOKE_AGENT
+ )
+
+ function_data = FunctionSpanData()
+ assert (
+ processor._get_operation_name(function_data)
+ == sp.GenAIOperationName.EXECUTE_TOOL
+ )
+
+ response_data = ResponseSpanData()
+ assert (
+ processor._get_operation_name(response_data)
+ == sp.GenAIOperationName.CHAT
+ )
+
+ class UnknownSpanData:
+ pass
+
+ unknown = UnknownSpanData()
+ assert processor._get_operation_name(unknown) == "unknown"
+
+ assert processor._get_span_kind(GenerationSpanData()) is SpanKind.CLIENT
+ assert processor._get_span_kind(FunctionSpanData()) is SpanKind.INTERNAL
+
+ assert (
+ sp.get_span_name(sp.GenAIOperationName.CHAT, model="gpt-4o")
+ == "chat gpt-4o"
+ )
+ assert (
+ sp.get_span_name(
+ sp.GenAIOperationName.EXECUTE_TOOL, tool_name="weather"
+ )
+ == "execute_tool weather"
+ )
+ assert (
+ sp.get_span_name(sp.GenAIOperationName.INVOKE_AGENT, agent_name=None)
+ == "invoke_agent"
+ )
+ assert (
+ sp.get_span_name(sp.GenAIOperationName.CREATE_AGENT, agent_name=None)
+ == "create_agent"
+ )
+
+
+def test_attribute_builders(processor_setup):
+ processor, _ = processor_setup
+
+ payload = sp.ContentPayload(
+ input_messages=[
+ {
+ "role": "user",
+ "parts": [{"type": "text", "content": "hi"}],
+ }
+ ],
+ output_messages=[
+ {
+ "role": "assistant",
+ "parts": [{"type": "text", "content": "hello"}],
+ }
+ ],
+ system_instructions=[{"type": "text", "content": "be helpful"}],
+ )
+ model_config = {
+ "base_url": "https://api.openai.com:443/v1",
+ "temperature": 0.2,
+ "top_p": 0.9,
+ "top_k": 3,
+ "frequency_penalty": 0.1,
+ "presence_penalty": 0.4,
+ "seed": 1234,
+ "n": 2,
+ "max_tokens": 128,
+ "stop": ["foo", None, "bar"],
+ }
+ generation_span = GenerationSpanData(
+ input=[{"role": "user"}],
+ output=[{"finish_reason": "stop"}],
+ model="gpt-4o",
+ model_config=model_config,
+ usage={
+ "prompt_tokens": 10,
+ "completion_tokens": 3,
+ "total_tokens": 13,
+ },
+ )
+ gen_attrs = _collect(
+ processor._get_attributes_from_generation_span_data(
+ generation_span, payload
+ )
+ )
+ assert gen_attrs[sp.GEN_AI_REQUEST_MODEL] == "gpt-4o"
+ assert gen_attrs[sp.GEN_AI_REQUEST_MAX_TOKENS] == 128
+ assert gen_attrs[sp.GEN_AI_REQUEST_STOP_SEQUENCES] == [
+ "foo",
+ None,
+ "bar",
+ ]
+ assert gen_attrs[ServerAttributes.SERVER_ADDRESS] == "api.openai.com"
+ assert gen_attrs[ServerAttributes.SERVER_PORT] == 443
+ assert gen_attrs[sp.GEN_AI_USAGE_INPUT_TOKENS] == 10
+ assert gen_attrs[sp.GEN_AI_USAGE_OUTPUT_TOKENS] == 3
+ assert gen_attrs[sp.GEN_AI_RESPONSE_FINISH_REASONS] == ["stop"]
+ assert json.loads(gen_attrs[sp.GEN_AI_INPUT_MESSAGES])[0]["role"] == "user"
+ assert (
+ json.loads(gen_attrs[sp.GEN_AI_OUTPUT_MESSAGES])[0]["role"]
+ == "assistant"
+ )
+ assert (
+ json.loads(gen_attrs[sp.GEN_AI_SYSTEM_INSTRUCTIONS])[0]["content"]
+ == "be helpful"
+ )
+ assert gen_attrs[sp.GEN_AI_OUTPUT_TYPE] == sp.GenAIOutputType.TEXT
+
+ class _Usage:
+ def __init__(self) -> None:
+ self.input_tokens = None
+ self.prompt_tokens = 7
+ self.output_tokens = None
+ self.completion_tokens = 2
+ self.total_tokens = 9
+
+ class _Response:
+ def __init__(self) -> None:
+ self.id = "resp-1"
+ self.model = "gpt-4o"
+ self.usage = _Usage()
+ self.output = [{"finish_reason": "stop"}]
+
+ response_span = ResponseSpanData(response=_Response())
+ response_attrs = _collect(
+ processor._get_attributes_from_response_span_data(
+ response_span, sp.ContentPayload()
+ )
+ )
+ assert response_attrs[sp.GEN_AI_RESPONSE_ID] == "resp-1"
+ assert response_attrs[sp.GEN_AI_RESPONSE_MODEL] == "gpt-4o"
+ assert response_attrs[sp.GEN_AI_RESPONSE_FINISH_REASONS] == ["stop"]
+ assert response_attrs[sp.GEN_AI_USAGE_INPUT_TOKENS] == 7
+ assert response_attrs[sp.GEN_AI_USAGE_OUTPUT_TOKENS] == 2
+ assert response_attrs[sp.GEN_AI_OUTPUT_TYPE] == sp.GenAIOutputType.TEXT
+
+ agent_span = AgentSpanData(
+ name="helper",
+ output_type="json",
+ description="desc",
+ agent_id="agent-123",
+ model="model-x",
+ operation="invoke_agent",
+ )
+ agent_attrs = _collect(
+ processor._get_attributes_from_agent_span_data(agent_span, None)
+ )
+ assert agent_attrs[sp.GEN_AI_AGENT_NAME] == "helper"
+ assert agent_attrs[sp.GEN_AI_AGENT_ID] == "agent-123"
+ assert agent_attrs[sp.GEN_AI_REQUEST_MODEL] == "model-x"
+ assert agent_attrs[sp.GEN_AI_OUTPUT_TYPE] == sp.GenAIOutputType.TEXT
+
+ # Fallback to aggregated model when span data lacks it
+ agent_span_no_model = AgentSpanData(
+ name="helper-2",
+ output_type="json",
+ description="desc",
+ agent_id="agent-456",
+ operation="invoke_agent",
+ )
+ agent_content = {
+ "input_messages": [],
+ "output_messages": [],
+ "system_instructions": [],
+ "request_model": "gpt-fallback",
+ }
+ agent_attrs_fallback = _collect(
+ processor._get_attributes_from_agent_span_data(
+ agent_span_no_model, agent_content
+ )
+ )
+ assert agent_attrs_fallback[sp.GEN_AI_REQUEST_MODEL] == "gpt-fallback"
+
+ function_span = FunctionSpanData(name="lookup_weather")
+ function_span.tool_type = "extension"
+ function_span.call_id = "call-42"
+ function_span.description = "desc"
+ function_payload = sp.ContentPayload(
+ tool_arguments={"city": "seattle"},
+ tool_result={"temperature": 70},
+ )
+ function_attrs = _collect(
+ processor._get_attributes_from_function_span_data(
+ function_span, function_payload
+ )
+ )
+ assert function_attrs[sp.GEN_AI_TOOL_NAME] == "lookup_weather"
+ assert function_attrs[sp.GEN_AI_TOOL_TYPE] == "extension"
+ assert function_attrs[sp.GEN_AI_TOOL_CALL_ID] == "call-42"
+ assert function_attrs[sp.GEN_AI_TOOL_DESCRIPTION] == "desc"
+ assert function_attrs[sp.GEN_AI_TOOL_CALL_ARGUMENTS] == {"city": "seattle"}
+ assert function_attrs[sp.GEN_AI_TOOL_CALL_RESULT] == {"temperature": 70}
+ assert function_attrs[sp.GEN_AI_OUTPUT_TYPE] == sp.GenAIOutputType.JSON
+
+
+def test_extract_genai_attributes_unknown_type(processor_setup):
+ processor, _ = processor_setup
+
+ class UnknownSpanData:
+ pass
+
+ class StubSpan:
+ def __init__(self) -> None:
+ self.span_data = UnknownSpanData()
+
+ attrs = _collect(
+ processor._extract_genai_attributes(
+ StubSpan(), sp.ContentPayload(), None
+ )
+ )
+ assert attrs[sp.GEN_AI_PROVIDER_NAME] == "openai"
+ assert attrs[sp.GEN_AI_SYSTEM_KEY] == "openai"
+ assert sp.GEN_AI_OPERATION_NAME not in attrs
+
+
+def test_span_status_helper():
+ status = sp._get_span_status(
+ SimpleNamespace(error={"message": "boom", "data": "bad"})
+ )
+ assert status.status_code is StatusCode.ERROR
+ assert status.description == "boom: bad"
+
+ ok_status = sp._get_span_status(SimpleNamespace(error=None))
+ assert ok_status.status_code is StatusCode.OK
+
+
+@dataclass
+class FakeTrace:
+ name: str
+ trace_id: str
+ started_at: str | None = None
+ ended_at: str | None = None
+
+
+@dataclass
+class FakeSpan:
+ trace_id: str
+ span_id: str
+ span_data: Any
+ parent_id: str | None = None
+ started_at: str | None = None
+ ended_at: str | None = None
+ error: dict[str, Any] | None = None
+
+
+def test_span_lifecycle_and_shutdown(processor_setup):
+ processor, exporter = processor_setup
+
+ trace = FakeTrace(
+ name="workflow",
+ trace_id="trace-1",
+ started_at="not-a-timestamp",
+ ended_at="2024-01-01T00:00:05Z",
+ )
+ processor.on_trace_start(trace)
+
+ parent_span = FakeSpan(
+ trace_id="trace-1",
+ span_id="span-1",
+ span_data=AgentSpanData(
+ operation="invoke", name="agent", model="gpt-4o"
+ ),
+ started_at="2024-01-01T00:00:00Z",
+ ended_at="2024-01-01T00:00:02Z",
+ )
+ processor.on_span_start(parent_span)
+
+ missing_span = FakeSpan(
+ trace_id="trace-1",
+ span_id="missing",
+ span_data=FunctionSpanData(name="lookup"),
+ started_at="2024-01-01T00:00:01Z",
+ ended_at="2024-01-01T00:00:02Z",
+ )
+ processor.on_span_end(missing_span)
+
+ child_span = FakeSpan(
+ trace_id="trace-1",
+ span_id="span-2",
+ parent_id="span-1",
+ span_data=FunctionSpanData(name="lookup"),
+ started_at="2024-01-01T00:00:02Z",
+ ended_at="2024-01-01T00:00:03Z",
+ error={"message": "boom", "data": "bad"},
+ )
+ processor.on_span_start(child_span)
+ processor.on_span_end(child_span)
+
+ processor.on_span_end(parent_span)
+ processor.on_trace_end(trace)
+
+ linger_trace = FakeTrace(
+ name="linger",
+ trace_id="trace-2",
+ started_at="2024-01-01T00:00:06Z",
+ )
+ processor.on_trace_start(linger_trace)
+ linger_span = FakeSpan(
+ trace_id="trace-2",
+ span_id="span-3",
+ span_data=AgentSpanData(operation=None),
+ started_at="2024-01-01T00:00:06Z",
+ )
+ processor.on_span_start(linger_span)
+
+ assert processor.force_flush() is None
+ processor.shutdown()
+
+ finished = exporter.get_finished_spans()
+ statuses = {span.name: span.status for span in finished}
+
+ assert (
+ statuses["execute_tool lookup"].status_code is StatusCode.ERROR
+ and statuses["execute_tool lookup"].description == "boom: bad"
+ )
+ assert statuses["invoke_agent agent"].status_code is StatusCode.OK
+ assert (
+ statuses["invoke_agent"].status_code is StatusCode.ERROR
+ and statuses["invoke_agent"].description == "Application shutdown"
+ )
+
+
+def test_chat_span_renamed_with_model(processor_setup):
+ processor, exporter = processor_setup
+
+ trace = FakeTrace(name="workflow", trace_id="trace-rename")
+ processor.on_trace_start(trace)
+
+ agent = FakeSpan(
+ trace_id=trace.trace_id,
+ span_id="agent-span",
+ span_data=AgentSpanData(
+ operation="invoke_agent",
+ name="Agent",
+ ),
+ started_at="2025-01-01T00:00:00Z",
+ ended_at="2025-01-01T00:00:02Z",
+ )
+ processor.on_span_start(agent)
+
+ generation_data = GenerationSpanData(
+ input=[{"role": "user", "content": "question"}],
+ output=[{"finish_reason": "stop"}],
+ usage={"prompt_tokens": 1, "completion_tokens": 1},
+ )
+ generation_span = FakeSpan(
+ trace_id=trace.trace_id,
+ span_id="child-span",
+ parent_id=agent.span_id,
+ span_data=generation_data,
+ started_at="2025-01-01T00:00:00Z",
+ ended_at="2025-01-01T00:00:01Z",
+ )
+ processor.on_span_start(generation_span)
+
+ # Model becomes available before span end (e.g., once response arrives)
+ generation_data.model = "gpt-4o"
+
+ processor.on_span_end(generation_span)
+ processor.on_span_end(agent)
+ processor.on_trace_end(trace)
+
+ span_names = {span.name for span in exporter.get_finished_spans()}
+ assert "chat gpt-4o" in span_names
+
+
+def test_workflow_and_step_entities_created(processor_setup):
+ processor, exporter = processor_setup
+
+ trace = FakeTrace(name="workflow", trace_id="trace-steps")
+ processor.on_trace_start(trace)
+
+ # Workflow entity should be created when the trace starts.
+ assert processor._workflow is not None
+ assert getattr(processor._workflow, "name", None) == "OpenAIAgents"
+
+ agent_span = FakeSpan(
+ trace_id=trace.trace_id,
+ span_id="agent-span",
+ span_data=AgentSpanData(
+ operation="invoke_agent",
+ name="Helper",
+ model="gpt-4o",
+ ),
+ started_at="2025-01-01T00:00:00Z",
+ ended_at="2025-01-01T00:00:01Z",
+ )
+
+ processor.on_span_start(agent_span)
+
+ # A Step entity should be created and linked to the workflow.
+ step = processor._steps.get(agent_span.span_id)
+ assert step is not None
+ assert getattr(step, "parent_run_id", None) == processor._workflow.run_id
+
+ processor.on_span_end(agent_span)
+ processor.on_trace_end(trace)
+
+ # Step map should be cleared once the agent span ends.
+ assert agent_span.span_id not in processor._steps
+
+ # Workflow should be cleared after trace end.
+ assert processor._workflow is None
+
+ # Exported spans remain valid.
+ finished = exporter.get_finished_spans()
+ names = {span.name for span in finished}
+ assert "invoke_agent Helper" in names
+
+
+def test_llm_and_tool_entities_lifecycle(processor_setup):
+ processor, exporter = processor_setup
+
+ trace = FakeTrace(name="workflow", trace_id="trace-llm-tool")
+ processor.on_trace_start(trace)
+
+ agent_span = FakeSpan(
+ trace_id=trace.trace_id,
+ span_id="agent-span",
+ span_data=AgentSpanData(operation="invoke_agent", name="Agent"),
+ started_at="2025-01-01T00:00:00Z",
+ ended_at="2025-01-01T00:00:02Z",
+ )
+ processor.on_span_start(agent_span)
+
+ # Generation (LLM) child span
+ generation_data = GenerationSpanData(
+ input=[{"role": "user", "content": "question"}],
+ output=[{"finish_reason": "stop"}],
+ model="gpt-4o",
+ )
+ generation_span = FakeSpan(
+ trace_id=trace.trace_id,
+ span_id="llm-span",
+ parent_id=agent_span.span_id,
+ span_data=generation_data,
+ started_at="2025-01-01T00:00:01Z",
+ ended_at="2025-01-01T00:00:02Z",
+ )
+
+ processor.on_span_start(generation_span)
+
+ # LLMInvocation may be created; when present it should be parented to the Step.
+ llm_entity = processor._llms.get(generation_span.span_id)
+ step = processor._steps.get(agent_span.span_id)
+ if llm_entity is not None and step is not None:
+ assert getattr(llm_entity, "parent_run_id", None) == step.run_id
+
+ processor.on_span_end(generation_span)
+ assert generation_span.span_id not in processor._llms
+
+ # Function (tool) child span
+ function_data = FunctionSpanData(name="lookup")
+ function_span = FakeSpan(
+ trace_id=trace.trace_id,
+ span_id="tool-span",
+ parent_id=agent_span.span_id,
+ span_data=function_data,
+ started_at="2025-01-01T00:00:02Z",
+ ended_at="2025-01-01T00:00:03Z",
+ )
+
+ processor.on_span_start(function_span)
+
+ tool_entity = processor._tools.get(function_span.span_id)
+ assert tool_entity is not None
+
+ if step is not None:
+ assert getattr(tool_entity, "parent_run_id", None) == step.run_id
+
+ processor.on_span_end(function_span)
+ processor.on_span_end(agent_span)
+ processor.on_trace_end(trace)
+
+ # Internal maps should be cleaned up.
+ assert function_span.span_id not in processor._tools
+ assert agent_span.span_id not in processor._steps
+
+ # Sanity check that spans were exported as usual.
+ exported_names = {span.name for span in exporter.get_finished_spans()}
+ assert "invoke_agent Agent" in exported_names
+ assert (
+ "chat gpt-4o" in exported_names
+ or "text_completion gpt-4o" in exported_names
+ )
diff --git a/pyproject.toml b/pyproject.toml
index 15bf4d5..c239482 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,9 +4,9 @@ name = "splunk-opentelemetry-python-contrib"
version = "0.0.0" # This is not used.
requires-python = ">=3.10"
dependencies = [
- "opentelemetry-api",
- "opentelemetry-sdk",
- "opentelemetry-semantic-conventions",
+ "opentelemetry-api~=1.38.0",
+ "opentelemetry-sdk==1.38.0",
+ "opentelemetry-semantic-conventions==0.59b0",
"opentelemetry-test-utils",
"opentelemetry-exporter-prometheus-remote-write",
"opentelemetry-exporter-richconsole",
diff --git a/util/opentelemetry-util-genai/examples/langgraph_agent_example.py b/util/opentelemetry-util-genai/examples/langgraph_agent_example.py
index 3dc45ca..53bd611 100644
--- a/util/opentelemetry-util-genai/examples/langgraph_agent_example.py
+++ b/util/opentelemetry-util-genai/examples/langgraph_agent_example.py
@@ -344,9 +344,9 @@ def run_agent_with_telemetry(question: str):
telemetry_callback = TelemetryCallback()
# 1. Start Workflow
- print(f"\n{'='*80}")
+ print(f"\n{'=' * 80}")
print(f"QUESTION: {question}")
- print(f"{'='*80}\n")
+ print(f"{'=' * 80}\n")
workflow = Workflow(
name="capital_question_workflow",
@@ -358,9 +358,9 @@ def run_agent_with_telemetry(question: str):
handler.start_workflow(workflow)
# 2. Create Agent with all attributes populated
- print(f"\n{'='*80}")
+ print(f"\n{'=' * 80}")
print("Creating ReAct agent...")
- print(f"{'='*80}\n")
+ print(f"{'=' * 80}\n")
agent_obj = AgentCreation(
name="capital_agent",
agent_type="react",
@@ -385,9 +385,9 @@ def run_agent_with_telemetry(question: str):
handler.stop_agent(agent_obj)
# 3. Invoke Agent
- print(f"\n{'='*80}")
+ print(f"\n{'=' * 80}")
print("Invoking agent...")
- print(f"{'='*80}\n")
+ print(f"{'=' * 80}\n")
agent_invocation = AgentInvocation(
name="capital_agent",
agent_type="react",
@@ -618,7 +618,7 @@ def run_agent_with_telemetry(question: str):
handler.stop_workflow(workflow)
# Log captured telemetry summary
- print(f"\n{'='*80}")
+ print(f"\n{'=' * 80}")
print("Telemetry Summary:")
print(f" LLM calls captured: {len(telemetry_callback.llm_calls)}")
print(f" Tool calls captured: {len(telemetry_callback.tool_calls)}")
@@ -629,11 +629,11 @@ def run_agent_with_telemetry(question: str):
print(f" Chain/Graph executions: {len(telemetry_callback.chain_calls)}")
if telemetry_callback.agent_actions:
print(f" Agent actions: {len(telemetry_callback.agent_actions)}")
- print(f"{'='*80}\n")
+ print(f"{'=' * 80}\n")
- print(f"\n{'='*80}")
+ print(f"\n{'=' * 80}")
print(f"FINAL ANSWER: {final_answer}")
- print(f"{'='*80}\n")
+ print(f"{'=' * 80}\n")
return final_answer
@@ -657,9 +657,9 @@ def main():
run_agent_with_telemetry(question)
# Wait for metrics to export
- print(f"\n{'='*80}")
+ print(f"\n{'=' * 80}")
print("Waiting for metrics export...")
- print(f"{'='*80}\n")
+ print(f"{'=' * 80}\n")
time.sleep(6)
diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/emitters/span.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/emitters/span.py
index 5d1eab0..8129d26 100644
--- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/emitters/span.py
+++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/emitters/span.py
@@ -595,7 +595,7 @@ def _error_agent(
# ---- Step lifecycle --------------------------------------------------
def _start_step(self, step: Step) -> None:
"""Start a step span."""
- span_name = f"step_execution {step.name}"
+ span_name = f"step {step.name}"
parent_span = getattr(step, "parent_span", None)
parent_ctx = (
trace.set_span_in_context(parent_span)
@@ -771,3 +771,4 @@ def _error_embedding(
token.__exit__(None, None, None) # type: ignore[misc]
except Exception:
pass
+ span.end()
diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py
index 49a5e12..c596969 100644
--- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py
+++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/utils.py
@@ -11,7 +11,7 @@
# 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.
-
+import json
import logging
import os
from typing import Optional
@@ -180,3 +180,13 @@ def parse_callback_filter(value: Optional[str]) -> set[str] | None:
item.strip().lower() for item in value.split(",") if item.strip()
}
return selected or None
+
+
+def gen_ai_json_dumps(value: object) -> str:
+ """
+ Serialize GenAI payloads to JSON.
+
+ This is the helper expected by openai-agents-v2 span_processor.safe_json_dumps.
+ It should behave like json.dumps, but can be extended later (e.g., custom encoder).
+ """
+ return json.dumps(value, ensure_ascii=False)