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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Capture non-sensitive ``EmbedContentConfig`` fields on Google GenAI embedding spans.
Original file line number Diff line number Diff line change
Expand Up @@ -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
************

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
)
Expand Down Expand Up @@ -89,8 +93,38 @@ def _apply_embedding_response_attributes(
pass


def _apply_embedding_request_attributes(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You are right: issue #170 explicitly calls for EmbedContentConfig to follow GenerateContentConfig, so treating small config values as safe by default was inconsistent. e258f09 now defaults embedding-config capture off and adds the matching OTEL_GOOGLE_GENAI_EMBED_CONTENT_CONFIG_INCLUDES / ..._EXCLUDES controls. I also added coverage for default omission and explicit opt-in; the focused embedding and instrumentor tests pass (7 tests).

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"},
)
Comment thread
tomatotomata marked this conversation as resolved.
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],
Expand All @@ -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)
Expand All @@ -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],
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down