-
Notifications
You must be signed in to change notification settings - Fork 1k
feat(gemini): migrate google-generativeai to latest OTel GenAI semantic conventions #3840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
f839c29
c13d0e2
a61054f
22c8e67
8683440
3c24e3c
8548ce2
3d67642
0f352b3
f58b77d
e99a6eb
4a21396
ce00bb5
2a6da1a
7d013af
63aa0a2
07d03cc
501e3b6
13ee5e9
2d7cd0b
723b8e6
e809593
4b04b86
5106246
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -32,8 +32,6 @@ | |
| ) | ||
| from opentelemetry.semconv_ai import ( | ||
| SUPPRESS_LANGUAGE_MODEL_INSTRUMENTATION_KEY, | ||
| LLMRequestTypeValues, | ||
| SpanAttributes, | ||
| Meters | ||
| ) | ||
| from opentelemetry.metrics import Meter, get_meter | ||
|
|
@@ -205,8 +203,8 @@ async def _awrap( | |
| name, | ||
| kind=SpanKind.CLIENT, | ||
| attributes={ | ||
| GenAIAttributes.GEN_AI_SYSTEM: "Google", | ||
| SpanAttributes.LLM_REQUEST_TYPE: LLMRequestTypeValues.COMPLETION.value, | ||
| GenAIAttributes.GEN_AI_PROVIDER_NAME: "gcp.gen_ai", | ||
| GenAIAttributes.GEN_AI_OPERATION_NAME: "chat", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. use |
||
| }, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ) | ||
| start_time = time.perf_counter() | ||
|
|
@@ -224,7 +222,7 @@ async def _awrap( | |
| duration_histogram.record( | ||
| duration, | ||
| attributes={ | ||
| GenAIAttributes.GEN_AI_PROVIDER_NAME: "Google", | ||
| GenAIAttributes.GEN_AI_PROVIDER_NAME: "gcp.gen_ai", | ||
| GenAIAttributes.GEN_AI_RESPONSE_MODEL: llm_model, | ||
| }, | ||
| ) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
@@ -281,8 +279,8 @@ def _wrap( | |
| name, | ||
| kind=SpanKind.CLIENT, | ||
| attributes={ | ||
| GenAIAttributes.GEN_AI_SYSTEM: "Google", | ||
| SpanAttributes.LLM_REQUEST_TYPE: LLMRequestTypeValues.COMPLETION.value, | ||
| GenAIAttributes.GEN_AI_PROVIDER_NAME: "gcp.gen_ai", | ||
| GenAIAttributes.GEN_AI_OPERATION_NAME: "chat", | ||
| }, | ||
| ) | ||
|
|
||
|
|
@@ -301,7 +299,7 @@ def _wrap( | |
| duration_histogram.record( | ||
| duration, | ||
| attributes={ | ||
| GenAIAttributes.GEN_AI_PROVIDER_NAME: "Google", | ||
| GenAIAttributes.GEN_AI_PROVIDER_NAME: "gcp.gen_ai", | ||
| GenAIAttributes.GEN_AI_RESPONSE_MODEL: llm_model, | ||
| }, | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,9 @@ | |
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
| GEN_AI_INPUT_MESSAGES = "gen_ai.input.messages" | ||
| GEN_AI_OUTPUT_MESSAGES = "gen_ai.output.messages" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. import from |
||
|
|
||
|
|
||
| def _set_span_attribute(span, name, value): | ||
| if value is not None: | ||
|
|
@@ -160,20 +163,6 @@ async def _process_content_part(part, span, part_index): | |
| return {"type": "text", "text": str(part)} | ||
|
|
||
|
|
||
| def _set_prompt_attributes(span, prompt_index, processed_content, content_item): | ||
| """Set span attributes for a processed prompt""" | ||
| _set_span_attribute( | ||
| span, | ||
| f"{SpanAttributes.LLM_PROMPTS}.{prompt_index}.content", | ||
| json.dumps(processed_content), | ||
| ) | ||
| _set_span_attribute( | ||
| span, | ||
| f"{SpanAttributes.LLM_PROMPTS}.{prompt_index}.role", | ||
| getattr(content_item, "role", "user"), | ||
| ) | ||
|
|
||
|
|
||
| async def _process_argument(argument, span): | ||
| """Process a single argument from args list""" | ||
| processed_content = [] | ||
|
|
@@ -213,49 +202,36 @@ async def set_input_attributes(span, args, kwargs, llm_model): | |
| if not should_send_prompts(): | ||
| return | ||
|
|
||
| messages = [] | ||
|
|
||
| if "contents" in kwargs: | ||
| contents = kwargs["contents"] | ||
| if isinstance(contents, str): | ||
| # Simple string content in OpenAI format | ||
| _set_span_attribute( | ||
| span, | ||
| f"{GenAIAttributes.GEN_AI_PROMPT}.0.content", | ||
| contents, | ||
| ) | ||
| _set_span_attribute( | ||
| span, | ||
| f"{GenAIAttributes.GEN_AI_PROMPT}.0.role", | ||
| "user", | ||
| ) | ||
| messages.append({"role": "user", "content": contents}) | ||
|
avivhalfon marked this conversation as resolved.
Outdated
|
||
| elif isinstance(contents, list): | ||
| for prompt_index, content_item in enumerate(contents): | ||
| for content_item in contents: | ||
| processed_content = await _process_content_item(content_item, span) | ||
|
|
||
| if processed_content: | ||
| _set_prompt_attributes(span, prompt_index, processed_content, content_item) | ||
|
|
||
| messages.append({ | ||
| "role": getattr(content_item, "role", "user"), | ||
| "content": json.dumps(processed_content), | ||
| }) | ||
| elif args and len(args) > 0: | ||
| # Handle args - process each argument | ||
| for arg_index, argument in enumerate(args): | ||
| for argument in args: | ||
| processed_content = await _process_argument(argument, span) | ||
|
|
||
| if processed_content: | ||
| _set_span_attribute( | ||
| span, | ||
| f"{SpanAttributes.LLM_PROMPTS}.{arg_index}.content", | ||
| json.dumps(processed_content), | ||
| ) | ||
| _set_span_attribute( | ||
| span, | ||
| f"{SpanAttributes.LLM_PROMPTS}.{arg_index}.role", | ||
| "user", | ||
| ) | ||
| messages.append({ | ||
| "role": "user", | ||
| "content": json.dumps(processed_content), | ||
| }) | ||
| elif "prompt" in kwargs: | ||
| _set_span_attribute( | ||
| span, f"{SpanAttributes.LLM_PROMPTS}.0.content", | ||
| json.dumps([{"type": "text", "text": kwargs["prompt"]}]) | ||
| ) | ||
| _set_span_attribute(span, f"{SpanAttributes.LLM_PROMPTS}.0.role", "user") | ||
| messages.append({ | ||
| "role": "user", | ||
| "content": json.dumps([{"type": "text", "text": kwargs["prompt"]}]), | ||
| }) | ||
|
|
||
| if messages: | ||
| _set_span_attribute(span, GEN_AI_INPUT_MESSAGES, json.dumps(messages)) | ||
|
|
||
|
avivhalfon marked this conversation as resolved.
|
||
|
|
||
| # Keep sync version for backward compatibility | ||
|
|
@@ -268,27 +244,20 @@ def set_input_attributes_sync(span, args, kwargs, llm_model): | |
| if not should_send_prompts(): | ||
| return | ||
|
|
||
| messages = [] | ||
|
|
||
| if "contents" in kwargs: | ||
| contents = kwargs["contents"] | ||
| if isinstance(contents, str): | ||
| # Simple string content in OpenAI format | ||
| _set_span_attribute( | ||
| span, | ||
| f"{GenAIAttributes.GEN_AI_PROMPT}.0.content", | ||
| json.dumps([{"type": "text", "text": contents}]), | ||
| ) | ||
| _set_span_attribute( | ||
| span, | ||
| f"{GenAIAttributes.GEN_AI_PROMPT}.0.role", | ||
| "user", | ||
| ) | ||
| messages.append({ | ||
| "role": "user", | ||
| "content": json.dumps([{"type": "text", "text": contents}]), | ||
| }) | ||
| elif isinstance(contents, list): | ||
| # Process content list - could be mixed text and Part objects | ||
| for i, content in enumerate(contents): | ||
| for content in contents: | ||
| processed_content = [] | ||
|
|
||
| if hasattr(content, "parts"): | ||
| # Content with parts (Google GenAI Content object) | ||
| for j, part in enumerate(content.parts): | ||
| if hasattr(part, "text") and part.text: | ||
| processed_content.append({"type": "text", "text": part.text}) | ||
|
|
@@ -299,36 +268,25 @@ def set_input_attributes_sync(span, args, kwargs, llm_model): | |
| if processed_image is not None: | ||
| processed_content.append(processed_image) | ||
| else: | ||
| # Other part types | ||
| processed_content.append({"type": "text", "text": str(part)}) | ||
| elif isinstance(content, str): | ||
| # Direct string in the list | ||
| processed_content.append({"type": "text", "text": content}) | ||
| elif _is_image_part(content): | ||
| # Direct Part object that's an image | ||
| processed_image = _process_image_part_sync( | ||
| content, span.context.trace_id, span.context.span_id, 0 | ||
| ) | ||
| if processed_image is not None: | ||
| processed_content.append(processed_image) | ||
| else: | ||
| # Other content types | ||
| processed_content.append({"type": "text", "text": str(content)}) | ||
|
|
||
| if processed_content: | ||
| _set_span_attribute( | ||
| span, | ||
| f"{SpanAttributes.LLM_PROMPTS}.{i}.content", | ||
| json.dumps(processed_content), | ||
| ) | ||
| _set_span_attribute( | ||
| span, | ||
| f"{SpanAttributes.LLM_PROMPTS}.{i}.role", | ||
| getattr(content, "role", "user"), | ||
| ) | ||
| messages.append({ | ||
| "role": getattr(content, "role", "user"), | ||
| "content": json.dumps(processed_content), | ||
| }) | ||
| elif args and len(args) > 0: | ||
| # Handle args - process each argument | ||
| for i, arg in enumerate(args): | ||
| for arg in args: | ||
| processed_content = [] | ||
|
|
||
| if isinstance(arg, str): | ||
|
|
@@ -355,22 +313,18 @@ def set_input_attributes_sync(span, args, kwargs, llm_model): | |
| processed_content.append({"type": "text", "text": str(arg)}) | ||
|
|
||
| if processed_content: | ||
| _set_span_attribute( | ||
| span, | ||
| f"{SpanAttributes.LLM_PROMPTS}.{i}.content", | ||
| json.dumps(processed_content), | ||
| ) | ||
| _set_span_attribute( | ||
| span, | ||
| f"{SpanAttributes.LLM_PROMPTS}.{i}.role", | ||
| "user", | ||
| ) | ||
| messages.append({ | ||
| "role": "user", | ||
| "content": json.dumps(processed_content), | ||
| }) | ||
| elif "prompt" in kwargs: | ||
| _set_span_attribute( | ||
| span, f"{GenAIAttributes.GEN_AI_PROMPT}.0.content", | ||
| json.dumps([{"type": "text", "text": kwargs["prompt"]}]) | ||
| ) | ||
| _set_span_attribute(span, f"{GenAIAttributes.GEN_AI_PROMPT}.0.role", "user") | ||
| messages.append({ | ||
| "role": "user", | ||
| "content": json.dumps([{"type": "text", "text": kwargs["prompt"]}]), | ||
| }) | ||
|
|
||
| if messages: | ||
| _set_span_attribute(span, GEN_AI_INPUT_MESSAGES, json.dumps(messages)) | ||
|
|
||
|
|
||
| def set_model_request_attributes(span, kwargs, llm_model): | ||
|
|
@@ -386,18 +340,18 @@ def set_model_request_attributes(span, kwargs, llm_model): | |
| _set_span_attribute(span, GenAIAttributes.GEN_AI_REQUEST_TOP_P, kwargs.get("top_p")) | ||
| _set_span_attribute(span, GenAIAttributes.GEN_AI_REQUEST_TOP_K, kwargs.get("top_k")) | ||
| _set_span_attribute( | ||
| span, SpanAttributes.LLM_PRESENCE_PENALTY, kwargs.get("presence_penalty") | ||
| span, GenAIAttributes.GEN_AI_REQUEST_PRESENCE_PENALTY, kwargs.get("presence_penalty") | ||
| ) | ||
| _set_span_attribute( | ||
| span, SpanAttributes.LLM_FREQUENCY_PENALTY, kwargs.get("frequency_penalty") | ||
| span, GenAIAttributes.GEN_AI_REQUEST_FREQUENCY_PENALTY, kwargs.get("frequency_penalty") | ||
| ) | ||
|
|
||
| generation_config = kwargs.get("generation_config") | ||
| if generation_config and hasattr(generation_config, "response_schema"): | ||
| try: | ||
| _set_span_attribute( | ||
| span, | ||
| SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA, | ||
| SpanAttributes.GEN_AI_REQUEST_STRUCTURED_OUTPUT_SCHEMA, | ||
| json.dumps(generation_config.response_schema), | ||
| ) | ||
| except Exception: | ||
|
|
@@ -407,7 +361,7 @@ def set_model_request_attributes(span, kwargs, llm_model): | |
| try: | ||
| _set_span_attribute( | ||
| span, | ||
| SpanAttributes.LLM_REQUEST_STRUCTURED_OUTPUT_SCHEMA, | ||
| SpanAttributes.GEN_AI_REQUEST_STRUCTURED_OUTPUT_SCHEMA, | ||
| json.dumps(kwargs.get("response_schema")), | ||
| ) | ||
| except Exception: | ||
|
|
@@ -418,32 +372,24 @@ def set_model_request_attributes(span, kwargs, llm_model): | |
| def set_response_attributes(span, response, llm_model): | ||
| if not should_send_prompts(): | ||
| return | ||
|
|
||
| messages = [] | ||
|
|
||
| if hasattr(response, "usage_metadata"): | ||
| if isinstance(response.text, list): | ||
| for index, item in enumerate(response): | ||
| prefix = f"{GenAIAttributes.GEN_AI_COMPLETION}.{index}" | ||
| _set_span_attribute(span, f"{prefix}.content", item.text) | ||
| _set_span_attribute(span, f"{prefix}.role", "assistant") | ||
| for item in response: | ||
| messages.append({"role": "assistant", "content": item.text}) | ||
| elif isinstance(response.text, str): | ||
| _set_span_attribute( | ||
| span, f"{GenAIAttributes.GEN_AI_COMPLETION}.0.content", response.text | ||
| ) | ||
| _set_span_attribute( | ||
| span, f"{GenAIAttributes.GEN_AI_COMPLETION}.0.role", "assistant" | ||
| ) | ||
| messages.append({"role": "assistant", "content": response.text}) | ||
| else: | ||
| if isinstance(response, list): | ||
| for index, item in enumerate(response): | ||
| prefix = f"{GenAIAttributes.GEN_AI_COMPLETION}.{index}" | ||
| _set_span_attribute(span, f"{prefix}.content", item) | ||
| _set_span_attribute(span, f"{prefix}.role", "assistant") | ||
| for item in response: | ||
| messages.append({"role": "assistant", "content": item}) | ||
| elif isinstance(response, str): | ||
| _set_span_attribute( | ||
| span, f"{GenAIAttributes.GEN_AI_COMPLETION}.0.content", response | ||
| ) | ||
| _set_span_attribute( | ||
| span, f"{GenAIAttributes.GEN_AI_COMPLETION}.0.role", "assistant" | ||
| ) | ||
| messages.append({"role": "assistant", "content": response}) | ||
|
|
||
| if messages: | ||
| _set_span_attribute(span, GEN_AI_OUTPUT_MESSAGES, json.dumps(messages)) | ||
|
|
||
|
|
||
| def set_model_response_attributes(span, response, llm_model, token_histogram): | ||
|
|
@@ -455,7 +401,7 @@ def set_model_response_attributes(span, response, llm_model, token_histogram): | |
| if hasattr(response, "usage_metadata"): | ||
| _set_span_attribute( | ||
| span, | ||
| SpanAttributes.LLM_USAGE_TOTAL_TOKENS, | ||
| SpanAttributes.GEN_AI_USAGE_TOTAL_TOKENS, | ||
| response.usage_metadata.total_token_count, | ||
| ) | ||
| _set_span_attribute( | ||
|
|
@@ -473,18 +419,18 @@ def set_model_response_attributes(span, response, llm_model, token_histogram): | |
| token_histogram.record( | ||
| response.usage_metadata.prompt_token_count, | ||
| attributes={ | ||
| GenAIAttributes.GEN_AI_PROVIDER_NAME: "Google", | ||
| GenAIAttributes.GEN_AI_PROVIDER_NAME: "gcp.gen_ai", | ||
| GenAIAttributes.GEN_AI_TOKEN_TYPE: "input", | ||
| GenAIAttributes.GEN_AI_RESPONSE_MODEL: llm_model, | ||
| } | ||
| ) | ||
| token_histogram.record( | ||
| response.usage_metadata.candidates_token_count, | ||
| attributes={ | ||
| GenAIAttributes.GEN_AI_PROVIDER_NAME: "Google", | ||
| GenAIAttributes.GEN_AI_TOKEN_TYPE: "output", | ||
| GenAIAttributes.GEN_AI_RESPONSE_MODEL: llm_model, | ||
| }, | ||
| ) | ||
| response.usage_metadata.candidates_token_count, | ||
| attributes={ | ||
| GenAIAttributes.GEN_AI_PROVIDER_NAME: "gcp.gen_ai", | ||
| GenAIAttributes.GEN_AI_TOKEN_TYPE: "output", | ||
| GenAIAttributes.GEN_AI_RESPONSE_MODEL: llm_model, | ||
| }, | ||
| ) | ||
|
Comment on lines
+745
to
+765
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check if there are any existing None guards for usage_metadata fields in the codebase
rg -n "prompt_token_count|candidates_token_count" --type=py -C2Repository: traceloop/openllmetry Length of output: 16250 🏁 Script executed: # Find the import statement and type definitions for usage_metadata
rg -n "usage_metadata|UsageMetadata" packages/opentelemetry-instrumentation-google-generativeai/ --type=py -A2 -B2 | head -50Repository: traceloop/openllmetry Length of output: 813 🏁 Script executed: # Check the type of um (usage_metadata) in the span_utils file context
rg -n "def.*um" packages/opentelemetry-instrumentation-google-generativeai/opentelemetry/instrumentation/google_generativeai/span_utils.py | head -20Repository: traceloop/openllmetry Length of output: 206 🏁 Script executed: # Look at the function signature around line 641 to understand um type
sed -n '600,660p' packages/opentelemetry-instrumentation-google-generativeai/opentelemetry/instrumentation/google_generativeai/span_utils.pyRepository: traceloop/openllmetry Length of output: 1977 🏁 Script executed: # Check if there are any tests covering the histogram recording with None values
find packages/opentelemetry-instrumentation-google-generativeai/tests -name "*.py" -type f -exec grep -l "token_histogram\|prompt_token_count" {} \;Repository: traceloop/openllmetry Length of output: 47 🏁 Script executed: # Check imports and type hints in the span_utils file
head -50 packages/opentelemetry-instrumentation-google-generativeai/opentelemetry/instrumentation/google_generativeai/span_utils.py | grep -E "^import|^from|typing"Repository: traceloop/openllmetry Length of output: 439 🏁 Script executed: # Check histogram.record method - find its implementation
rg -n "histogram.record\|class.*Histogram" packages/opentelemetry-instrumentation-google-generativeai/ --type=py -B3 -A3Repository: traceloop/openllmetry Length of output: 47 🏁 Script executed: # Check if google-generativeai is specified and what version
find packages/opentelemetry-instrumentation-google-generativeai -name "pyproject.toml" -o -name "setup.py" -o -name "requirements*.txt" | xargs cat 2>/dev/null | grep -A5 -B5 "google-generativeai"Repository: traceloop/openllmetry Length of output: 712 🏁 Script executed: # Search the broader codebase for how histogram.record is used elsewhere and if None values are ever passed
rg -n "histogram\.record" --type=py -B5 -A2Repository: traceloop/openllmetry Length of output: 50377 🏁 Script executed: # Search for type information about usage_metadata in the codebase
rg -n "from google" packages/opentelemetry-instrumentation-google-generativeai/ --type=py | head -20Repository: traceloop/openllmetry Length of output: 684 🏁 Script executed: # Check if there are any error handling or try-catch blocks around histogram recording in google-generativeai
sed -n '630,670p' packages/opentelemetry-instrumentation-google-generativeai/opentelemetry/instrumentation/google_generativeai/span_utils.py | cat -nRepository: traceloop/openllmetry Length of output: 1286 🏁 Script executed: # Check if there are any test failures or comments about None values in the tests
find packages/opentelemetry-instrumentation-google-generativeai/tests -type f -name "*.py" -exec cat {} \; | grep -i "none\|null\|token" | head -30Repository: traceloop/openllmetry Length of output: 959 🏁 Script executed: # Check if google-genai is a stub or if we can find type hints for UsageMetadata
find . -name "*.pyi" -o -name "py.typed" | xargs grep -l "prompt_token_count" 2>/dev/null || echo "No type stubs found"Repository: traceloop/openllmetry Length of output: 84 🏁 Script executed: # Look for the actual function that calls set_model_response_attributes to understand response handling
rg -n "set_model_response_attributes" packages/opentelemetry-instrumentation-google-generativeai/ --type=py -B5 -A2Repository: traceloop/openllmetry Length of output: 6377 🌐 Web query:
💡 Result: In the Google GenAI SDK (google-genai Python package), GenerateContentResponse has an attribute usage_metadata which provides token usage details from the Gemini API response. prompt_token_count is an integer field within usage_metadata representing the number of tokens in the input prompt. Access it as: response.usage_metadata.prompt_token_count This is an optional field (may be None if not present), of type int when available. Example usage: from google import genai client = genai.Client response = client.models.generate_content(model='gemini-2.5-flash', contents='Your prompt here') if response.usage_metadata: print(response.usage_metadata.prompt_token_count) usage_metadata type is a Pydantic model (from google.genai.types) with fields like: - prompt_token_count: int (input tokens) - candidates_token_count: int (output tokens) - total_token_count: int - thoughts_token_count: int (for thinking models) - cached_content_token_count: int (if using caching) Confirmed in official Gemini API docs and SDK references. Vertex AI REST API equivalent is UsageMetadata with promptTokenCount (camelCase). Citations:
Add None checks before recording token counts to histogram. The fields Suggested fix if token_histogram and um is not None:
+ if um.prompt_token_count is not None:
token_histogram.record(
um.prompt_token_count,
attributes={
GenAIAttributes.GEN_AI_PROVIDER_NAME: _GCP_GEN_AI,
GenAIAttributes.GEN_AI_TOKEN_TYPE: "input",
GenAIAttributes.GEN_AI_RESPONSE_MODEL: llm_model,
},
)
+ if um.candidates_token_count is not None:
token_histogram.record(
um.candidates_token_count,
attributes={
GenAIAttributes.GEN_AI_PROVIDER_NAME: _GCP_GEN_AI,
GenAIAttributes.GEN_AI_TOKEN_TYPE: "output",
GenAIAttributes.GEN_AI_RESPONSE_MODEL: llm_model,
},
)🤖 Prompt for AI Agents |
||
|
|
||
| span.set_status(Status(StatusCode.OK)) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
cant you import from
GenAiSystemValues.<gcp>.value?