Fix a few bugs in opentelemetry-instrumentation-google-genai instrumentation package (#3905)

* Fix a few bugs in gen AI instrumentation

* Make a lot of changes

* Remove print statements

* fix lint issues

* remove added folder

* Address comments

* Move code into helper

* Revert change to pyright include
This commit is contained in:
DylanRussell
2025-11-12 13:08:49 -05:00
committed by GitHub
parent bd3c1f2aa2
commit 123f55615d
9 changed files with 295 additions and 202 deletions

View File

@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
- Minor change to check LRU cache in Completion Hook before acquiring semaphore/thread ([#3907](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3907)).
- Ensure log event is written and completion hook is called even when model call results in exception. Put new
log event (` gen_ai.client.inference.operation.details`) behind the flag `OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental`.
Ensure same sem conv attributes are on the log and span. Fix an issue where the instrumentation would crash when a pydantic.BaseModel class was passed as the response schema ([#3905](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3905)).
## Version 0.4b0 (2025-10-16)

View File

@@ -151,7 +151,7 @@ def _flatten_compound_value_using_json(
)
def _flatten_compound_value(
def _flatten_compound_value( # pylint: disable=too-many-return-statements
key: str,
value: Any,
exclude_keys: Set[str],
@@ -189,13 +189,16 @@ def _flatten_compound_value(
flatten_functions=flatten_functions,
)
if hasattr(value, "model_dump"):
return _flatten_dict(
value.model_dump(),
key_prefix=key,
exclude_keys=exclude_keys,
rename_keys=rename_keys,
flatten_functions=flatten_functions,
)
try:
return _flatten_dict(
value.model_dump(),
key_prefix=key,
exclude_keys=exclude_keys,
rename_keys=rename_keys,
flatten_functions=flatten_functions,
)
except TypeError:
return {key: str(value)}
return _flatten_compound_value_using_json(
key,
value,

View File

@@ -162,30 +162,24 @@ def _to_dict(value: object):
if isinstance(value, dict):
return value
if hasattr(value, "model_dump"):
return value.model_dump()
try:
return value.model_dump()
except TypeError:
return {"ModelName": str(value)}
return json.loads(json.dumps(value))
def _add_request_options_to_span(
span: Span,
def _create_request_attributes(
config: Optional[GenerateContentConfigOrDict],
is_experimental_mode: bool,
allow_list: AllowList,
):
if config is None:
return
span_context = span.get_span_context()
if not span_context.trace_flags.sampled:
# Avoid potentially costly traversal of config
# options if the span will be dropped, anyway.
return
# Automatically derive attributes from the contents of the
# config object. This ensures that all relevant parameters
# are captured in the telemetry data (except for those
# that are excluded via "exclude_keys"). Dynamic attributes (those
# starting with "gcp.gen_ai." instead of simply "gen_ai.request.")
# are filtered with the "allow_list" before inclusion in the span.
) -> dict[str, Any]:
if not config:
return {}
config = _to_dict(config)
attributes = flatten_dict(
_to_dict(config),
config,
# A custom prefix is used, because the names/structure of the
# configuration is likely to be specific to Google Gen AI SDK.
key_prefix=GCP_GENAI_OPERATION_CONFIG,
@@ -212,37 +206,21 @@ def _add_request_options_to_span(
"gcp.gen_ai.operation.config.seed": gen_ai_attributes.GEN_AI_REQUEST_SEED,
},
)
for key, value in attributes.items():
if key.startswith(
GCP_GENAI_OPERATION_CONFIG
) and not allow_list.allowed(key):
# The allowlist is used to control inclusion of the dynamic keys.
continue
span.set_attribute(key, value)
def _get_gen_ai_request_attributes(
config: Union[GenerateContentConfigOrDict, None],
) -> dict[str, Any]:
if not config:
return {}
attributes: dict[str, Any] = {}
config = _coerce_config_to_object(config)
if config.seed:
attributes[gen_ai_attributes.GEN_AI_REQUEST_SEED] = config.seed
if config.candidate_count:
attributes[gen_ai_attributes.GEN_AI_REQUEST_CHOICE_COUNT] = (
config.candidate_count
)
if config.response_mime_type:
if config.response_mime_type == "text/plain":
response_mime_type = config.get("response_mime_type")
if response_mime_type and is_experimental_mode:
if response_mime_type == "text/plain":
attributes[gen_ai_attributes.GEN_AI_OUTPUT_TYPE] = "text"
elif config.response_mime_type == "application/json":
elif response_mime_type == "application/json":
attributes[gen_ai_attributes.GEN_AI_OUTPUT_TYPE] = "json"
else:
attributes[gen_ai_attributes.GEN_AI_OUTPUT_TYPE] = (
config.response_mime_type
response_mime_type
)
for key in list(attributes.keys()):
if key.startswith(
GCP_GENAI_OPERATION_CONFIG
) and not allow_list.allowed(key):
del attributes[key]
return attributes
@@ -372,19 +350,25 @@ class _GenerateContentInstrumentationHelper:
end_on_exit=end_on_exit,
)
def add_request_options_to_span(
self, config: Optional[GenerateContentConfigOrDict]
):
span = trace.get_current_span()
_add_request_options_to_span(
span, config, self._generate_content_config_key_allowlist
)
def create_final_attributes(self) -> dict[str, Any]:
final_attributes = {
gen_ai_attributes.GEN_AI_USAGE_INPUT_TOKENS: self._input_tokens,
gen_ai_attributes.GEN_AI_USAGE_OUTPUT_TOKENS: self._output_tokens,
gen_ai_attributes.GEN_AI_RESPONSE_FINISH_REASONS: sorted(
self._finish_reasons_set
),
}
if self._error_type:
final_attributes[error_attributes.ERROR_TYPE] = self._error_type
return final_attributes
def process_request(
self,
contents: Union[ContentListUnion, ContentListUnionDict],
config: Optional[GenerateContentConfigOrDict],
span: Span,
):
span.set_attribute(gen_ai_attributes.GEN_AI_SYSTEM, self._genai_system)
self._maybe_log_system_instruction(config=config)
self._maybe_log_user_prompt(contents)
@@ -393,39 +377,9 @@ class _GenerateContentInstrumentationHelper:
self._maybe_log_response(response)
self._response_index += 1
def process_completion(
self,
request: Union[ContentListUnion, ContentListUnionDict],
response: GenerateContentResponse,
config: Optional[GenerateContentConfigOrDict] = None,
):
self._update_response(response)
self._maybe_log_completion_details(
request, response.candidates or [], config
)
def process_error(self, e: Exception):
self._error_type = str(e.__class__.__name__)
def finalize_processing(self):
span = trace.get_current_span()
span.set_attribute(
gen_ai_attributes.GEN_AI_USAGE_INPUT_TOKENS, self._input_tokens
)
span.set_attribute(
gen_ai_attributes.GEN_AI_USAGE_OUTPUT_TOKENS, self._output_tokens
)
span.set_attribute(
gen_ai_attributes.GEN_AI_RESPONSE_FINISH_REASONS,
sorted(self._finish_reasons_set),
)
if self.sem_conv_opt_in_mode == _StabilityMode.DEFAULT:
span.set_attribute(
gen_ai_attributes.GEN_AI_SYSTEM, self._genai_system
)
self._record_token_usage_metric()
self._record_duration_metric()
def _update_response(self, response: GenerateContentResponse):
# TODO: Determine if there are other response properties that
# need to be reflected back into the span attributes.
@@ -488,11 +442,17 @@ class _GenerateContentInstrumentationHelper:
def _maybe_log_completion_details(
self,
request_attributes: dict[str, Any],
final_attributes: dict[str, Any],
request: Union[ContentListUnion, ContentListUnionDict],
candidates: list[Candidate],
config: Optional[GenerateContentConfigOrDict] = None,
):
attributes = _get_gen_ai_request_attributes(config)
if (
self.sem_conv_opt_in_mode
!= _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
):
return
system_instructions = []
if system_content := _config_to_system_instruction(config):
system_instructions = to_system_instructions(
@@ -506,7 +466,7 @@ class _GenerateContentInstrumentationHelper:
span = trace.get_current_span()
event = LogRecord(
event_name="gen_ai.client.inference.operation.details",
attributes=attributes,
attributes=request_attributes | final_attributes,
)
self.completion_hook.on_completion(
inputs=input_messages,
@@ -540,7 +500,7 @@ class _GenerateContentInstrumentationHelper:
for k, v in completion_details_attributes.items()
}
)
span.set_attributes(attributes)
# request attributes were already set on the span..
def _maybe_log_system_instruction(
self, config: Optional[GenerateContentConfigOrDict] = None
@@ -748,6 +708,7 @@ def _create_instrumented_generate_content(
config: Optional[GenerateContentConfigOrDict] = None,
**kwargs: Any,
) -> GenerateContentResponse:
candidates = []
helper = _GenerateContentInstrumentationHelper(
self,
otel_wrapper,
@@ -755,12 +716,21 @@ def _create_instrumented_generate_content(
completion_hook,
generate_content_config_key_allowlist=generate_content_config_key_allowlist,
)
is_experimental_mode = (
helper.sem_conv_opt_in_mode
== _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
)
request_attributes = _create_request_attributes(
config,
is_experimental_mode,
helper._generate_content_config_key_allowlist,
)
with helper.start_span_as_current_span(
model, "google.genai.Models.generate_content"
):
helper.add_request_options_to_span(config)
) as span:
span.set_attributes(request_attributes)
if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT:
helper.process_request(contents, config)
helper.process_request(contents, config, span)
try:
response = wrapped_func(
self,
@@ -769,23 +739,29 @@ def _create_instrumented_generate_content(
config=helper.wrapped_config(config),
**kwargs,
)
if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT:
helper.process_response(response)
elif (
helper.sem_conv_opt_in_mode
== _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
):
helper.process_completion(contents, response, config)
if is_experimental_mode:
helper._update_response(response)
if response.candidates:
candidates += response.candidates
else:
raise ValueError(
f"Sem Conv opt in mode {helper.sem_conv_opt_in_mode} not supported."
)
helper.process_response(response)
return response
except Exception as error:
helper.process_error(error)
raise
finally:
helper.finalize_processing()
final_attributes = helper.create_final_attributes()
span.set_attributes(final_attributes)
helper._maybe_log_completion_details(
request_attributes,
final_attributes,
contents,
candidates,
config,
)
helper._record_token_usage_metric()
helper._record_duration_metric()
return instrumented_generate_content
@@ -815,12 +791,21 @@ def _create_instrumented_generate_content_stream(
completion_hook,
generate_content_config_key_allowlist=generate_content_config_key_allowlist,
)
is_experimental_mode = (
helper.sem_conv_opt_in_mode
== _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
)
request_attributes = _create_request_attributes(
config,
is_experimental_mode,
helper._generate_content_config_key_allowlist,
)
with helper.start_span_as_current_span(
model, "google.genai.Models.generate_content_stream"
):
helper.add_request_options_to_span(config)
) as span:
span.set_attributes(request_attributes)
if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT:
helper.process_request(contents, config)
helper.process_request(contents, config, span)
try:
for response in wrapped_func(
self,
@@ -829,28 +814,29 @@ def _create_instrumented_generate_content_stream(
config=helper.wrapped_config(config),
**kwargs,
):
if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT:
helper.process_response(response)
elif (
helper.sem_conv_opt_in_mode
== _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
):
if is_experimental_mode:
helper._update_response(response)
if response.candidates:
candidates += response.candidates
else:
raise ValueError(
f"Sem Conv opt in mode {helper.sem_conv_opt_in_mode} not supported."
)
helper.process_response(response)
yield response
except Exception as error:
helper.process_error(error)
raise
finally:
final_attributes = helper.create_final_attributes()
span.set_attributes(final_attributes)
helper._maybe_log_completion_details(
contents, candidates, config
request_attributes,
final_attributes,
contents,
candidates,
config,
)
helper.finalize_processing()
helper._record_token_usage_metric()
helper._record_duration_metric()
return instrumented_generate_content_stream
@@ -879,12 +865,22 @@ def _create_instrumented_async_generate_content(
completion_hook,
generate_content_config_key_allowlist=generate_content_config_key_allowlist,
)
is_experimental_mode = (
helper.sem_conv_opt_in_mode
== _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
)
request_attributes = _create_request_attributes(
config,
is_experimental_mode,
helper._generate_content_config_key_allowlist,
)
candidates: list[Candidate] = []
with helper.start_span_as_current_span(
model, "google.genai.AsyncModels.generate_content"
):
helper.add_request_options_to_span(config)
) as span:
span.set_attributes(request_attributes)
if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT:
helper.process_request(contents, config)
helper.process_request(contents, config, span)
try:
response = await wrapped_func(
self,
@@ -893,23 +889,28 @@ def _create_instrumented_async_generate_content(
config=helper.wrapped_config(config),
**kwargs,
)
if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT:
helper.process_response(response)
elif (
helper.sem_conv_opt_in_mode
== _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
):
helper.process_completion(contents, response, config)
if is_experimental_mode:
helper._update_response(response)
if response.candidates:
candidates += response.candidates
else:
raise ValueError(
f"Sem Conv opt in mode {helper.sem_conv_opt_in_mode} not supported."
)
helper.process_response(response)
return response
except Exception as error:
helper.process_error(error)
raise
finally:
helper.finalize_processing()
final_attributes = helper.create_final_attributes()
span.set_attributes(final_attributes)
helper._maybe_log_completion_details(
request_attributes,
final_attributes,
contents,
candidates,
config,
)
helper._record_token_usage_metric()
helper._record_duration_metric()
return instrumented_generate_content
@@ -939,14 +940,23 @@ def _create_instrumented_async_generate_content_stream( # type: ignore
completion_hook,
generate_content_config_key_allowlist=generate_content_config_key_allowlist,
)
is_experimental_mode = (
helper.sem_conv_opt_in_mode
== _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
)
request_attributes = _create_request_attributes(
config,
is_experimental_mode,
helper._generate_content_config_key_allowlist,
)
with helper.start_span_as_current_span(
model,
"google.genai.AsyncModels.generate_content_stream",
end_on_exit=False,
) as span:
helper.add_request_options_to_span(config)
if helper.sem_conv_opt_in_mode == _StabilityMode.DEFAULT:
helper.process_request(contents, config)
span.set_attributes(request_attributes)
if not is_experimental_mode:
helper.process_request(contents, config, span)
try:
response_async_generator = await wrapped_func(
self,
@@ -957,7 +967,17 @@ def _create_instrumented_async_generate_content_stream( # type: ignore
)
except Exception as error: # pylint: disable=broad-exception-caught
helper.process_error(error)
helper.finalize_processing()
helper._record_token_usage_metric()
final_attributes = helper.create_final_attributes()
span.set_attributes(final_attributes)
helper._maybe_log_completion_details(
request_attributes,
final_attributes,
contents,
[],
config,
)
helper._record_duration_metric()
with trace.use_span(span, end_on_exit=True):
raise
@@ -966,31 +986,29 @@ def _create_instrumented_async_generate_content_stream( # type: ignore
with trace.use_span(span, end_on_exit=True):
try:
async for response in response_async_generator:
if (
helper.sem_conv_opt_in_mode
== _StabilityMode.DEFAULT
):
helper.process_response(response)
elif (
helper.sem_conv_opt_in_mode
== _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
):
if is_experimental_mode:
helper._update_response(response)
if response.candidates:
candidates += response.candidates
else:
raise ValueError(
f"Sem Conv opt in mode {helper.sem_conv_opt_in_mode} not supported."
)
helper.process_response(response)
yield response
except Exception as error:
helper.process_error(error)
raise
finally:
final_attributes = helper.create_final_attributes()
span.set_attributes(final_attributes)
helper._maybe_log_completion_details(
contents, candidates, config
request_attributes,
final_attributes,
contents,
candidates,
config,
)
helper.finalize_processing()
helper._record_token_usage_metric()
helper._record_duration_metric()
return _response_async_generator_wrapper()
@@ -1007,6 +1025,14 @@ def instrument_generate_content(
completion_hook: CompletionHook,
generate_content_config_key_allowlist: Optional[AllowList] = None,
) -> object:
opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode(
_OpenTelemetryStabilitySignalType.GEN_AI
)
if opt_in_mode not in (
_StabilityMode.GEN_AI_LATEST_EXPERIMENTAL,
_StabilityMode.DEFAULT,
):
raise ValueError(f"Sem Conv opt in mode {opt_in_mode} not supported.")
snapshot = _MethodsSnapshot()
Models.generate_content = _create_instrumented_generate_content(
snapshot,

View File

@@ -14,9 +14,14 @@
import os
import unittest
from unittest.mock import patch
import google.genai
from opentelemetry.instrumentation._semconv import (
_OpenTelemetrySemanticConventionStability,
)
from .auth import FakeCredentials
from .instrumentation_context import InstrumentationContext
from .otel_mocker import OTelMocker
@@ -24,6 +29,16 @@ from .otel_mocker import OTelMocker
class TestCase(unittest.TestCase):
def setUp(self):
# Most tests want this environment variable setup. Need to figure out a less hacky way of doing this.
with patch.dict(
"os.environ",
{
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true",
"OTEL_SEMCONV_STABILITY_OPT_IN": "default",
},
):
_OpenTelemetrySemanticConventionStability._initialized = False
_OpenTelemetrySemanticConventionStability._initialize()
self._otel = OTelMocker()
self._otel.install()
self._instrumentation_context = None

View File

@@ -94,16 +94,19 @@ class TestCase(CommonTestCaseBase):
response = create_response(**kwargs)
self._responses.append(response)
def _create_and_install_mocks(self):
def configure_exception(self, e, **kwargs):
self._create_and_install_mocks(e)
def _create_and_install_mocks(self, e=None):
if self._generate_content_mock is not None:
return
self.reset_client()
self.reset_instrumentation()
self._generate_content_mock = self._create_nonstream_mock()
self._generate_content_stream_mock = self._create_stream_mock()
self._generate_content_mock = self._create_nonstream_mock(e)
self._generate_content_stream_mock = self._create_stream_mock(e)
self._install_mocks()
def _create_nonstream_mock(self):
def _create_nonstream_mock(self, e=None):
mock = unittest.mock.MagicMock()
def _default_impl(*args, **kwargs):
@@ -114,17 +117,20 @@ class TestCase(CommonTestCaseBase):
self._response_index += 1
return result
mock.side_effect = _default_impl
mock.side_effect = e or _default_impl
return mock
def _create_stream_mock(self):
def _create_stream_mock(self, e=None):
mock = unittest.mock.MagicMock()
def _default_impl(*args, **kwargs):
for response in self._responses:
yield response
mock.side_effect = _default_impl
if not e:
mock.side_effect = _default_impl
else:
mock.side_effect = e
return mock
def _install_mocks(self):

View File

@@ -16,7 +16,9 @@ import json
import unittest
from unittest.mock import patch
import pytest
from google.genai.types import GenerateContentConfig
from pydantic import BaseModel, Field
from opentelemetry._events import Event
from opentelemetry.instrumentation._semconv import (
@@ -31,6 +33,12 @@ from opentelemetry.util.genai.types import ContentCapturingMode
from .base import TestCase
# pylint: disable=too-many-public-methods
class ExampleResponseSchema(BaseModel):
name: str = Field(description="A Destination's Name")
class NonStreamingTestCase(TestCase):
# The "setUp" function is defined by "unittest.TestCase" and thus
@@ -92,6 +100,40 @@ class NonStreamingTestCase(TestCase):
span.attributes["gen_ai.operation.name"], "generate_content"
)
def test_span_and_event_still_written_when_response_is_exception(self):
self.configure_exception(ValueError("Uh oh!"))
patched_environ = patch.dict(
"os.environ",
{
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "SPAN_AND_EVENT",
"OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental",
},
)
with patched_environ:
_OpenTelemetrySemanticConventionStability._initialized = False
_OpenTelemetrySemanticConventionStability._initialize()
with pytest.raises(ValueError):
self.generate_content(
model="gemini-2.0-flash", contents="Does this work?"
)
self.otel.assert_has_span_named(
"generate_content gemini-2.0-flash"
)
span = self.otel.get_span_named(
"generate_content gemini-2.0-flash"
)
self.otel.assert_has_event_named(
"gen_ai.client.inference.operation.details"
)
event = self.otel.get_event_named(
"gen_ai.client.inference.operation.details"
)
assert (
span.attributes["error.type"]
== event.attributes["error.type"]
== "ValueError"
)
def test_generated_span_has_correct_function_name(self):
self.configure_valid_response(text="Yep, it works!")
self.generate_content(
@@ -215,6 +257,12 @@ class NonStreamingTestCase(TestCase):
self.assertEqual(event_record.attributes["gen_ai.system"], "gemini")
self.assertEqual(event_record.body["content"], "<elided>")
@patch.dict(
"os.environ",
{
"OTEL_GOOGLE_GENAI_GENERATE_CONTENT_CONFIG_INCLUDES": "gcp.gen_ai.operation.config.response_schema"
},
)
def test_new_semconv_record_completion_as_log(self):
for mode in ContentCapturingMode:
patched_environ = patch.dict(
@@ -243,7 +291,8 @@ class NonStreamingTestCase(TestCase):
model="gemini-2.0-flash",
contents=content,
config=GenerateContentConfig(
system_instruction=sys_instr
system_instruction=sys_instr,
response_schema=ExampleResponseSchema,
),
)
self.otel.assert_has_event_named(
@@ -252,6 +301,12 @@ class NonStreamingTestCase(TestCase):
event = self.otel.get_event_named(
"gen_ai.client.inference.operation.details"
)
assert (
event.attributes[
"gcp.gen_ai.operation.config.response_schema"
]
== "<class 'tests.generate_content.nonstreaming_base.ExampleResponseSchema'>"
)
if mode in [
ContentCapturingMode.NO_CONTENT,
ContentCapturingMode.SPAN_ONLY,
@@ -346,7 +401,8 @@ class NonStreamingTestCase(TestCase):
model="gemini-2.0-flash",
contents="Some input",
config=GenerateContentConfig(
system_instruction="System instruction"
system_instruction="System instruction",
response_schema=ExampleResponseSchema,
),
)
span = self.otel.get_span_named(

View File

@@ -172,6 +172,9 @@ def test_flatten_with_pydantic_model_value():
"foo.str_value": "bar",
"foo.int_value": 123,
}
assert dict_util.flatten_dict({"foo": PydanticModel}) == {
"foo": "<class 'tests.utils.test_dict_util.PydanticModel'>"
}
def test_flatten_with_model_dumpable_value():

View File

@@ -13,6 +13,7 @@
# limitations under the License.
import asyncio
import os
import unittest
from unittest.mock import patch
@@ -21,8 +22,6 @@ from google.genai import types as genai_types
from opentelemetry._logs import get_logger_provider
from opentelemetry.instrumentation._semconv import (
_OpenTelemetrySemanticConventionStability,
_OpenTelemetryStabilitySignalType,
_StabilityMode,
)
from opentelemetry.instrumentation.google_genai import (
otel_wrapper,
@@ -44,6 +43,12 @@ class TestCase(unittest.TestCase):
get_logger_provider(),
get_meter_provider(),
)
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = (
"true"
)
os.environ["OTEL_SEMCONV_STABILITY_OPT_IN"] = "default"
_OpenTelemetrySemanticConventionStability._initialized = False
_OpenTelemetrySemanticConventionStability._initialize()
@property
def otel(self):
@@ -169,10 +174,6 @@ class TestCase(unittest.TestCase):
"An example tool call function.",
)
@patch.dict(
"os.environ",
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
)
def test_handles_primitive_int_arg(self):
def somefunction(arg=None):
pass
@@ -191,10 +192,6 @@ class TestCase(unittest.TestCase):
span.attributes["code.function.parameters.arg.value"], 12345
)
@patch.dict(
"os.environ",
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
)
def test_handles_primitive_string_arg(self):
def somefunction(arg=None):
pass
@@ -214,10 +211,6 @@ class TestCase(unittest.TestCase):
"a string value",
)
@patch.dict(
"os.environ",
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
)
def test_handles_dict_arg(self):
def somefunction(arg=None):
pass
@@ -237,10 +230,6 @@ class TestCase(unittest.TestCase):
'{"key": "value"}',
)
@patch.dict(
"os.environ",
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
)
def test_handles_primitive_list_arg(self):
def somefunction(arg=None):
pass
@@ -262,10 +251,6 @@ class TestCase(unittest.TestCase):
[1, 2, 3],
)
@patch.dict(
"os.environ",
{"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": "true"},
)
def test_handles_heterogenous_list_arg(self):
def somefunction(arg=None):
pass
@@ -290,24 +275,19 @@ class TestCase(unittest.TestCase):
pass
for mode in ContentCapturingMode:
patched_environ = patch.dict(
"os.environ",
{
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": mode.name,
"OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental",
},
)
patched_otel_mapping = patch.dict(
_OpenTelemetrySemanticConventionStability._OTEL_SEMCONV_STABILITY_SIGNAL_MAPPING,
{
_OpenTelemetryStabilitySignalType.GEN_AI: _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL
},
)
with self.subTest(
f"mode: {mode}", patched_environ=patched_environ
):
with self.subTest(f"mode: {mode}"):
self.setUp()
with patched_environ, patched_otel_mapping:
with patch.dict(
"os.environ",
{
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT": mode.name,
"OTEL_SEMCONV_STABILITY_OPT_IN": "gen_ai_latest_experimental",
},
):
_OpenTelemetrySemanticConventionStability._initialized = (
False
)
_OpenTelemetrySemanticConventionStability._initialize()
wrapped_somefunction = self.wrap(somefunction)
wrapped_somefunction(12345)
@@ -328,4 +308,4 @@ class TestCase(unittest.TestCase):
"code.function.parameters.arg.value",
span.attributes,
)
self.tearDown()
self.tearDown()

View File

@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
- Minor change to check LRU cache in Completion Hook before acquiring semaphore/thread ([#3907](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3907)).
## Version 0.2b0 (2025-10-14)
- Add jsonlines support to fsspec uploader