Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion instrumentation-genai/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,32 @@ This layer is responsible only for:
Everything else (span creation, metric recording, event emission, context propagation)
belongs in `util/opentelemetry-util-genai`.

## 2. Invocation Pattern
## 2. TelemetryHandler Initialization

Construct `TelemetryHandler` once inside `_instrument()`, passing all OTel providers and the
completion hook. Always prefer an explicitly injected hook (`kwargs.get("completion_hook")`)
over the entry-point hook loaded by `load_completion_hook()`, so test code can override the
hook without touching the environment.

```python
from opentelemetry.util.genai.completion_hook import load_completion_hook
from opentelemetry.util.genai.handler import TelemetryHandler

def _instrument(self, **kwargs):
tracer_provider = kwargs.get("tracer_provider")
meter_provider = kwargs.get("meter_provider")
logger_provider = kwargs.get("logger_provider")

handler = TelemetryHandler(
tracer_provider=tracer_provider,
meter_provider=meter_provider,
logger_provider=logger_provider,
completion_hook=kwargs.get("completion_hook") or load_completion_hook(),
)
# pass handler to each patch/wrapper function
```

## 3. Invocation Pattern

Use `start_*()` and control span lifetime manually:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add strongly typed Responses API extractors with validation and content
extraction improvements
([#4337](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4337))
- Add completion hook support.
([#4315](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4315))
- Fix `response_format` handling: map `json_object`/`json_schema` to `json` output type.
([#4315](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4315))
- Skip attribute values with `openai.Omit` value.
([#4315](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4315))

## Version 2.3b0 (2025-12-24)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,11 @@
from opentelemetry.metrics import get_meter
from opentelemetry.semconv.schemas import Schemas
from opentelemetry.trace import get_tracer
from opentelemetry.util.genai.completion_hook import load_completion_hook
from opentelemetry.util.genai.handler import (
TelemetryHandler,
)
from opentelemetry.util.genai.types import ContentCapturingMode
from opentelemetry.util.genai.utils import (
get_content_capturing_mode,
is_experimental_mode,
)
from opentelemetry.util.genai.utils import is_experimental_mode

from .instruments import Instruments
from .patch import (
Expand Down Expand Up @@ -107,22 +104,19 @@ def _instrument(self, **kwargs):

instruments = Instruments(self._meter)

content_mode = (
get_content_capturing_mode()
if latest_experimental_enabled
else ContentCapturingMode.NO_CONTENT
)
handler = TelemetryHandler(
tracer_provider=tracer_provider,
meter_provider=meter_provider,
logger_provider=logger_provider,
completion_hook=kwargs.get("completion_hook")
or load_completion_hook(),
)

wrap_function_wrapper(
"openai.resources.chat.completions",
"Completions.create",
(
chat_completions_create_v_new(handler, content_mode)
chat_completions_create_v_new(handler)
if latest_experimental_enabled
else chat_completions_create_v_old(
tracer, logger, instruments, is_content_enabled()
Expand All @@ -134,7 +128,7 @@ def _instrument(self, **kwargs):
"openai.resources.chat.completions",
"AsyncCompletions.create",
(
async_chat_completions_create_v_new(handler, content_mode)
async_chat_completions_create_v_new(handler)
if latest_experimental_enabled
else async_chat_completions_create_v_old(
tracer, logger, instruments, is_content_enabled()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
from opentelemetry.trace.propagation import set_span_in_context
from opentelemetry.util.genai.handler import TelemetryHandler
from opentelemetry.util.genai.types import (
ContentCapturingMode,
Error,
LLMInvocation, # pylint: disable=no-name-in-module # TODO: migrate to InferenceInvocation
OutputMessage,
Expand Down Expand Up @@ -121,11 +120,9 @@ def traced_method(wrapped, instance, args, kwargs):

def chat_completions_create_v_new(
handler: TelemetryHandler,
content_capturing_mode: ContentCapturingMode,
):
"""Wrap the `create` method of the `ChatCompletion` class to trace it."""

capture_content = content_capturing_mode != ContentCapturingMode.NO_CONTENT
capture_content = handler.should_capture_content()

def traced_method(wrapped, instance, args, kwargs):
chat_invocation = handler.start_llm(
Expand Down Expand Up @@ -226,10 +223,9 @@ async def traced_method(wrapped, instance, args, kwargs):

def async_chat_completions_create_v_new(
handler: TelemetryHandler,
content_capturing_mode: ContentCapturingMode,
):
"""Wrap the `create` method of the `AsyncChatCompletion` class to trace it."""
capture_content = content_capturing_mode != ContentCapturingMode.NO_CONTENT
capture_content = handler.should_capture_content()

async def traced_method(wrapped, instance, args, kwargs):
chat_invocation = handler.start_llm(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from typing import Any, Iterable, List, Mapping
from urllib.parse import urlparse

import openai
from httpx import URL
from openai import NotGiven

Expand Down Expand Up @@ -48,6 +49,8 @@
ToolCallResponse,
)

_OpenAIOmit = getattr(openai, "Omit", None)


def is_content_enabled() -> bool:
capture_content = environ.get(
Expand Down Expand Up @@ -201,9 +204,17 @@ def non_numerical_value_is_set(value: bool | str | NotGiven | None):


def value_is_set(value):
if _OpenAIOmit is not None and isinstance(value, _OpenAIOmit):
return False
return value is not None and not isinstance(value, NotGiven)


def _openai_response_format_to_output_type(response_format_type: str) -> str:
if response_format_type in ("json_object", "json_schema"):
return "json"
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_openai_response_format_to_output_type() hardcodes the semconv output type as the string literal "json". For gen_ai.output.type the semantic conventions define a well-known value set; per instrumentation-genai guideline 1000002 §4, prefer using GenAIAttributes.GenAiOutputTypeValues.JSON.value (and other enum values) instead of raw strings.

Suggested change
return "json"
return GenAIAttributes.GenAiOutputTypeValues.JSON.value

Copilot uses AI. Check for mistakes.
return response_format_type


def get_llm_request_attributes(
kwargs,
client_instance,
Expand Down Expand Up @@ -282,7 +293,7 @@ def get_llm_request_attributes(
attributes[request_response_format_attr_key] = (
response_format_type
)
else:
elif isinstance(response_format, str):
attributes[request_response_format_attr_key] = response_format

# service_tier can be passed directly or in extra_body (in SDK 1.26.0 it's via extra_body)
Expand Down Expand Up @@ -374,12 +385,14 @@ def create_chat_invocation(
response_format_type := get_value(response_format.get("type"))
) is not None:
attributes[GenAIAttributes.GEN_AI_OUTPUT_TYPE] = (
response_format_type
_openai_response_format_to_output_type(
response_format_type
)
)
else:
attributes[
GenAIAttributes.GEN_AI_OPENAI_REQUEST_RESPONSE_FORMAT
] = response_format
elif isinstance(response_format, str):
attributes[GenAIAttributes.GEN_AI_OUTPUT_TYPE] = (
_openai_response_format_to_output_type(response_format)
)

# service_tier can be passed directly or in extra_body (in SDK 1.26.0 it's via extra_body)
service_tier = get_value(kwargs.get("service_tier"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,10 @@ def fixture_content_mode(request):

@pytest.fixture(scope="function")
def instrument_no_content(
tracer_provider, logger_provider, meter_provider, content_mode
tracer_provider,
logger_provider,
meter_provider,
content_mode,
):
_OpenTelemetrySemanticConventionStability._initialized = False
latest_experimental_enabled, _ = content_mode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
# This variant of the requirements aims to test the system using
# the newest supported version of external dependencies.

openai==1.109.1
openai==2.26.0
pydantic==2.12.5
httpx==0.27.2
# older jiter is required for PyPy < 3.11
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# 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.

import os
from unittest.mock import MagicMock, patch

from opentelemetry.instrumentation._semconv import (
OTEL_SEMCONV_STABILITY_OPT_IN,
_OpenTelemetrySemanticConventionStability,
)
from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor
from opentelemetry.util.genai.environment_variables import (
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT,
)

from .test_utils import DEFAULT_MODEL, USER_ONLY_PROMPT


@patch.dict(
os.environ,
{OTEL_SEMCONV_STABILITY_OPT_IN: "gen_ai_latest_experimental"},
)
def test_custom_hook_is_called(
span_exporter,
log_exporter,
tracer_provider,
logger_provider,
meter_provider,
openai_client,
vcr,
):
"""A hook passed to instrument() is called after each chat completion."""
hook = MagicMock()
instrumentor = OpenAIInstrumentor()
_OpenTelemetrySemanticConventionStability._initialized = False
instrumentor.instrument(
tracer_provider=tracer_provider,
logger_provider=logger_provider,
meter_provider=meter_provider,
completion_hook=hook,
)

try:
with vcr.use_cassette("test_chat_completion_with_content.yaml"):
openai_client.chat.completions.create(
messages=USER_ONLY_PROMPT,
model=DEFAULT_MODEL,
stream=False,
)
finally:
instrumentor.uninstrument()

hook.on_completion.assert_called_once()
kwargs = hook.on_completion.call_args.kwargs
assert kwargs["inputs"]
assert kwargs["outputs"]
assert kwargs["span"] is not None

# Content goes to the hook only — not to span attributes or log records
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span_attrs = spans[0].attributes or {}
assert "gen_ai.input.messages" not in span_attrs
assert "gen_ai.output.messages" not in span_attrs

assert log_exporter.get_finished_logs() == ()


@patch.dict(
os.environ,
{
OTEL_SEMCONV_STABILITY_OPT_IN: "gen_ai_latest_experimental",
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "span_only",
},
)
def test_default_hook_loaded_from_env(
span_exporter,
tracer_provider,
logger_provider,
meter_provider,
openai_client,
vcr,
):
"""When no hook kwarg is given, load_completion_hook() provides the default."""
default_hook = MagicMock()
instrumentor = OpenAIInstrumentor()
with patch(
"opentelemetry.instrumentation.openai_v2.load_completion_hook",
return_value=default_hook,
):
instrumentor.instrument(
tracer_provider=tracer_provider,
logger_provider=logger_provider,
meter_provider=meter_provider,
# no completion_hook kwarg — should fall back to load_completion_hook()
)

try:
with vcr.use_cassette("test_chat_completion_with_content.yaml"):
openai_client.chat.completions.create(
messages=USER_ONLY_PROMPT,
model=DEFAULT_MODEL,
stream=False,
)
finally:
instrumentor.uninstrument()

default_hook.on_completion.assert_called_once()
kwargs = default_hook.on_completion.call_args.kwargs
assert kwargs["inputs"]
assert kwargs["outputs"]
assert kwargs["span"] is not None

spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span_attrs = spans[0].attributes or {}
assert "gen_ai.input.messages" in span_attrs
assert "gen_ai.output.messages" in span_attrs
2 changes: 2 additions & 0 deletions util/opentelemetry-util-genai/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
of repeatedly failing on every upload ([#4390](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4390)).
- Refactor public API: add factory methods (`start_inference`, `start_embedding`, `start_tool`, `start_workflow`) and invocation-owned lifecycle (`invocation.stop()` / `invocation.fail(exc)`); rename `LLMInvocation` → `InferenceInvocation` and `ToolCall` → `ToolInvocation`. Existing usages remain fully functional via deprecated aliases.
([#4391](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4391))
- `TelemetryHandler` now accepts a `completion_hook` parameter and calls it after each LLM invocation, passing inputs, outputs, the active span, and the log record. Content capture is enabled automatically when a real hook is configured.
([#4315](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4315))
- Add metrics to ToolInvocations ([#4443](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4443))


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from opentelemetry.semconv.attributes import server_attributes
from opentelemetry.trace import SpanKind, Tracer
from opentelemetry.util.genai._invocation import Error, GenAIInvocation
from opentelemetry.util.genai.completion_hook import CompletionHook
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
from opentelemetry.util.types import AttributeValue

Expand All @@ -34,11 +35,12 @@ class EmbeddingInvocation(GenAIInvocation):
context manager rather than constructing this directly.
"""

def __init__(
def __init__( # pylint: disable=too-many-locals
self,
tracer: Tracer,
metrics_recorder: InvocationMetricsRecorder,
logger: Logger,
completion_hook: CompletionHook,
provider: str,
*,
request_model: str | None = None,
Expand All @@ -57,6 +59,7 @@ def __init__(
tracer,
metrics_recorder,
logger,
completion_hook,
operation_name=_operation_name,
span_name=f"{_operation_name} {request_model}"
if request_model
Expand Down
Loading
Loading