mirror of
https://github.com/open-telemetry/opentelemetry-python-contrib.git
synced 2026-03-13 08:10:39 +08:00
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user