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)