diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/170.added b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/170.added new file mode 100644 index 000000000..5549d545b --- /dev/null +++ b/instrumentation/opentelemetry-instrumentation-google-genai/.changelog/170.added @@ -0,0 +1 @@ +Capture non-sensitive ``EmbedContentConfig`` fields on Google GenAI embedding spans. diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/README.rst b/instrumentation/opentelemetry-instrumentation-google-genai/README.rst index 4a13300f9..dc9e4a979 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/README.rst +++ b/instrumentation/opentelemetry-instrumentation-google-genai/README.rst @@ -92,29 +92,45 @@ This controls whether the following content is captured on spans and/or events: Configuration recording *********************** -The instrumentation can optionally record ``GenerateContentConfig`` parameters -as span and event attributes under the ``gcp.gen_ai.operation.config.*`` namespace. +The instrumentation can optionally record ``GenerateContentConfig`` and +``EmbedContentConfig`` parameters under the +``gcp.gen_ai.operation.config.*`` namespace. Generate-content configuration +is recorded on both spans and events, while embedding configuration is +recorded on spans only. By default, no config fields are recorded. You can control which fields are captured using the following environment variables: -* ``OTEL_GOOGLE_GENAI_GENERATE_CONTENT_CONFIG_INCLUDES`` — A comma-separated - list of config field names to include in the span attributes. For example: +* ``OTEL_GOOGLE_GENAI_GENERATE_CONTENT_CONFIG_INCLUDES`` - A comma-separated + list of fully-qualified attribute keys to include. The keys are produced by + flattening the configuration, so use the complete key rather than only the + config field name. For example: .. code-block:: bash - export OTEL_GOOGLE_GENAI_GENERATE_CONTENT_CONFIG_INCLUDES=temperature,max_output_tokens + export OTEL_GOOGLE_GENAI_GENERATE_CONTENT_CONFIG_INCLUDES=gcp.gen_ai.operation.config.temperature,gcp.gen_ai.operation.config.max_output_tokens -* ``OTEL_GOOGLE_GENAI_GENERATE_CONTENT_CONFIG_EXCLUDES`` — A comma-separated - list of config field names to exclude from the span attributes: +* ``OTEL_GOOGLE_GENAI_GENERATE_CONTENT_CONFIG_EXCLUDES`` - A comma-separated + list of fully-qualified attribute keys to exclude: .. code-block:: bash - export OTEL_GOOGLE_GENAI_GENERATE_CONTENT_CONFIG_EXCLUDES=stop_sequences + export OTEL_GOOGLE_GENAI_GENERATE_CONTENT_CONFIG_EXCLUDES=gcp.gen_ai.operation.config.stop_sequences If both variables are set, the includes list is applied first, then the excludes list filters the result further. +Embedding configuration uses the same opt-in behavior, with its own variables: + +* ``OTEL_GOOGLE_GENAI_EMBED_CONTENT_CONFIG_INCLUDES`` +* ``OTEL_GOOGLE_GENAI_EMBED_CONTENT_CONFIG_EXCLUDES`` + +For example, to capture all embedding configuration fields: + +.. code-block:: bash + + export OTEL_GOOGLE_GENAI_EMBED_CONTENT_CONFIG_INCLUDES=* + Uninstrument ************ diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/embeddings.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/embeddings.py index 650df833f..ab517386e 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/embeddings.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/embeddings.py @@ -10,7 +10,7 @@ from google.genai._api_client import BaseApiClient from google.genai.models import AsyncModels, Models -from google.genai.types import EmbedContentResponse +from google.genai.types import EmbedContentConfig, EmbedContentResponse from wrapt import wrap_function_wrapper from opentelemetry.instrumentation.google_genai.client_info import ( @@ -24,6 +24,10 @@ EmbeddingInvocation, ) +from .allowlist_util import AllowList +from .custom_semconv import GCP_GENAI_OPERATION_CONFIG +from .dict_util import flatten_dict + _RAW_RESPONSE_BODY: ContextVar[str | None] = ContextVar( "raw_response_body", default=None ) @@ -89,8 +93,38 @@ def _apply_embedding_response_attributes( pass +def _apply_embedding_request_attributes( + config: EmbedContentConfig | dict[str, Any] | None, + allow_list: AllowList, + invocation: EmbeddingInvocation, +) -> None: + if config is None: + return + if isinstance(config, dict): + try: + config = EmbedContentConfig.model_validate(config) + except Exception: + return + + attributes = flatten_dict( + config.model_dump(exclude_none=True), + key_prefix=GCP_GENAI_OPERATION_CONFIG, + # HTTP options can contain request headers and must not be copied into + # telemetry attributes. + exclude_keys={f"{GCP_GENAI_OPERATION_CONFIG}.http_options"}, + ) + invocation.attributes.update( + { + key: value + for key, value in attributes.items() + if allow_list.allowed(key) + } + ) + + def _create_instrumented_embed_content( telemetry_handler: TelemetryHandler, + embed_content_config_key_allowlist: AllowList, ) -> Callable[ [ Callable[..., EmbedContentResponse], @@ -116,6 +150,11 @@ def instrumented_embed_content( request_model=kwargs.get("model"), server_address=server_address, ) as invocation: + _apply_embedding_request_attributes( + kwargs.get("config"), + embed_content_config_key_allowlist, + invocation, + ) response = wrapped(*args, **kwargs) _apply_embedding_response_attributes(response, invocation) _RAW_RESPONSE_BODY.set(None) @@ -126,6 +165,7 @@ def instrumented_embed_content( def _create_instrumented_async_embed_content( telemetry_handler: TelemetryHandler, + embed_content_config_key_allowlist: AllowList, ) -> Callable[ [ Callable[..., Any], @@ -151,6 +191,11 @@ async def instrumented_embed_content( request_model=kwargs.get("model"), server_address=server_address, ) as invocation: + _apply_embedding_request_attributes( + kwargs.get("config"), + embed_content_config_key_allowlist, + invocation, + ) response = await wrapped(*args, **kwargs) _apply_embedding_response_attributes(response, invocation) _RAW_RESPONSE_BODY.set(None) @@ -166,18 +211,23 @@ def uninstrument_embeddings(snapshot: object) -> None: def instrument_embeddings( telemetry_handler: TelemetryHandler, + embed_content_config_key_allowlist: AllowList, ) -> object: snapshot = _EmbeddingMethodsSnapshot() wrapped = wrap_function_wrapper( "google.genai.models", "Models.embed_content", - _create_instrumented_embed_content(telemetry_handler), + _create_instrumented_embed_content( + telemetry_handler, embed_content_config_key_allowlist + ), ) wrapped2 = wrap_function_wrapper( "google.genai.models", "AsyncModels.embed_content", - _create_instrumented_async_embed_content(telemetry_handler), + _create_instrumented_async_embed_content( + telemetry_handler, embed_content_config_key_allowlist + ), ) _set_co_filename(wrapped) _set_co_filename(wrapped2) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py index 46d3079c5..f5e90b5fa 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/src/opentelemetry/instrumentation/google_genai/instrumentor.py @@ -27,7 +27,9 @@ class GoogleGenAiSdkInstrumentor(BaseInstrumentor): def __init__( - self, generate_content_config_key_allowlist: Optional[AllowList] = None + self, + generate_content_config_key_allowlist: Optional[AllowList] = None, + embed_content_config_key_allowlist: Optional[AllowList] = None, ): self._generate_content_snapshot = None self._interactions_snapshot = None @@ -39,6 +41,13 @@ def __init__( excludes_env_var="OTEL_GOOGLE_GENAI_GENERATE_CONTENT_CONFIG_EXCLUDES", ) ) + self._embed_content_config_key_allowlist = ( + embed_content_config_key_allowlist + or AllowList.from_env( + "OTEL_GOOGLE_GENAI_EMBED_CONTENT_CONFIG_INCLUDES", + excludes_env_var="OTEL_GOOGLE_GENAI_EMBED_CONTENT_CONFIG_EXCLUDES", + ) + ) # Inherited, abstract function from 'BaseInstrumentor'. Even though 'self' is # not used in the definition, a method is required per the API contract. @@ -69,7 +78,10 @@ def _instrument(self, **kwargs: Any): self._interactions_snapshot = instrument_interactions( telemetry_handler, ) - self._embedding_snapshot = instrument_embeddings(telemetry_handler) + self._embedding_snapshot = instrument_embeddings( + telemetry_handler, + embed_content_config_key_allowlist=self._embed_content_config_key_allowlist, + ) def _uninstrument(self, **kwargs: Any): uninstrument_generate_content(self._generate_content_snapshot) diff --git a/instrumentation/opentelemetry-instrumentation-google-genai/tests/embeddings/test_embeddings.py b/instrumentation/opentelemetry-instrumentation-google-genai/tests/embeddings/test_embeddings.py index ac0e2a69e..3484c3808 100644 --- a/instrumentation/opentelemetry-instrumentation-google-genai/tests/embeddings/test_embeddings.py +++ b/instrumentation/opentelemetry-instrumentation-google-genai/tests/embeddings/test_embeddings.py @@ -2,13 +2,15 @@ # SPDX-License-Identifier: Apache-2.0 import asyncio -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch from google.genai.models import AsyncModels, Models from google.genai.types import ( ContentEmbedding, ContentEmbeddingStatistics, + EmbedContentConfig, EmbedContentResponse, + HttpOptions, ) from opentelemetry.semconv._incubating.attributes import ( @@ -128,6 +130,81 @@ async def run_test(): "generativelanguage.googleapis.com", ) + def test_embed_content_does_not_capture_config_attributes_by_default(self): + self.client.models.embed_content( + model="text-embedding-004", + contents="hello world", + config=EmbedContentConfig( + task_type="RETRIEVAL_QUERY", + output_dimensionality=256, + auto_truncate=True, + ), + ) + + span = self.otel.get_finished_spans()[0] + attrs = span.attributes + self.assertNotIn("gcp.gen_ai.operation.config.task_type", attrs) + self.assertNotIn( + "gcp.gen_ai.operation.config.output_dimensionality", attrs + ) + self.assertNotIn("gcp.gen_ai.operation.config.auto_truncate", attrs) + + @patch.dict( + "os.environ", + {"OTEL_GOOGLE_GENAI_EMBED_CONTENT_CONFIG_INCLUDES": "*"}, + ) + def test_async_embed_content_captures_config_attributes(self): + async def run_test(): + await self.client.aio.models.embed_content( + model="text-embedding-004", + contents="hello world", + config={ + "taskType": "RETRIEVAL_DOCUMENT", + "outputDimensionality": 128, + }, + ) + + asyncio.run(run_test()) + + span = self.otel.get_finished_spans()[0] + attrs = span.attributes + self.assertEqual( + attrs["gcp.gen_ai.operation.config.task_type"], + "RETRIEVAL_DOCUMENT", + ) + self.assertEqual( + attrs["gcp.gen_ai.operation.config.output_dimensionality"], 128 + ) + + @patch.dict( + "os.environ", + {"OTEL_GOOGLE_GENAI_EMBED_CONTENT_CONFIG_INCLUDES": "*"}, + ) + def test_sync_embed_content_captures_config_without_http_options(self): + self.client.models.embed_content( + model="text-embedding-004", + contents="hello world", + config=EmbedContentConfig( + task_type="RETRIEVAL_QUERY", + output_dimensionality=256, + http_options=HttpOptions(headers={"authorization": "secret"}), + ), + ) + + attrs = self.otel.get_finished_spans()[0].attributes + self.assertEqual( + attrs["gcp.gen_ai.operation.config.task_type"], "RETRIEVAL_QUERY" + ) + self.assertEqual( + attrs["gcp.gen_ai.operation.config.output_dimensionality"], 256 + ) + self.assertFalse( + any( + key.startswith("gcp.gen_ai.operation.config.http_options") + for key in attrs + ) + ) + def test_embed_content_multiple_inputs(self): _ = self.client.models.embed_content( model="text-embedding-004",