1818
1919from __future__ import annotations
2020
21+ import io
22+ import json
2123import logging
2224from typing import Any
2325
26+ from botocore .response import StreamingBody
27+
2428from opentelemetry .instrumentation .botocore .extensions .types import (
2529 _AttributeMapT ,
2630 _AwsSdkExtension ,
@@ -58,7 +62,7 @@ class _BedrockRuntimeExtension(_AwsSdkExtension):
5862 Amazon Bedrock Runtime</a>.
5963 """
6064
61- _HANDLED_OPERATIONS = {"Converse" }
65+ _HANDLED_OPERATIONS = {"Converse" , "InvokeModel" }
6266
6367 def extract_attributes (self , attributes : _AttributeMapT ):
6468 if self ._call_context .operation not in self ._HANDLED_OPERATIONS :
@@ -73,6 +77,7 @@ def extract_attributes(self, attributes: _AttributeMapT):
7377 GenAiOperationNameValues .CHAT .value
7478 )
7579
80+ # Converse
7681 if inference_config := self ._call_context .params .get (
7782 "inferenceConfig"
7883 ):
@@ -97,6 +102,84 @@ def extract_attributes(self, attributes: _AttributeMapT):
97102 inference_config .get ("stopSequences" ),
98103 )
99104
105+ # InvokeModel
106+ # Get the request body if it exists
107+ body = self ._call_context .params .get ("body" )
108+ if body :
109+ try :
110+ request_body = json .loads (body )
111+
112+ if "amazon.titan" in model_id :
113+ # titan interface is a text completion one
114+ attributes [GEN_AI_OPERATION_NAME ] = (
115+ GenAiOperationNameValues .TEXT_COMPLETION .value
116+ )
117+ self ._extract_titan_attributes (
118+ attributes , request_body
119+ )
120+ elif "amazon.nova" in model_id :
121+ self ._extract_nova_attributes (attributes , request_body )
122+ elif "anthropic.claude" in model_id :
123+ self ._extract_claude_attributes (
124+ attributes , request_body
125+ )
126+ except json .JSONDecodeError :
127+ _logger .debug ("Error: Unable to parse the body as JSON" )
128+
129+ def _extract_titan_attributes (self , attributes , request_body ):
130+ config = request_body .get ("textGenerationConfig" , {})
131+ self ._set_if_not_none (
132+ attributes , GEN_AI_REQUEST_TEMPERATURE , config .get ("temperature" )
133+ )
134+ self ._set_if_not_none (
135+ attributes , GEN_AI_REQUEST_TOP_P , config .get ("topP" )
136+ )
137+ self ._set_if_not_none (
138+ attributes , GEN_AI_REQUEST_MAX_TOKENS , config .get ("maxTokenCount" )
139+ )
140+ self ._set_if_not_none (
141+ attributes ,
142+ GEN_AI_REQUEST_STOP_SEQUENCES ,
143+ config .get ("stopSequences" ),
144+ )
145+
146+ def _extract_nova_attributes (self , attributes , request_body ):
147+ config = request_body .get ("inferenceConfig" , {})
148+ self ._set_if_not_none (
149+ attributes , GEN_AI_REQUEST_TEMPERATURE , config .get ("temperature" )
150+ )
151+ self ._set_if_not_none (
152+ attributes , GEN_AI_REQUEST_TOP_P , config .get ("topP" )
153+ )
154+ self ._set_if_not_none (
155+ attributes , GEN_AI_REQUEST_MAX_TOKENS , config .get ("max_new_tokens" )
156+ )
157+ self ._set_if_not_none (
158+ attributes ,
159+ GEN_AI_REQUEST_STOP_SEQUENCES ,
160+ config .get ("stopSequences" ),
161+ )
162+
163+ def _extract_claude_attributes (self , attributes , request_body ):
164+ self ._set_if_not_none (
165+ attributes ,
166+ GEN_AI_REQUEST_MAX_TOKENS ,
167+ request_body .get ("max_tokens" ),
168+ )
169+ self ._set_if_not_none (
170+ attributes ,
171+ GEN_AI_REQUEST_TEMPERATURE ,
172+ request_body .get ("temperature" ),
173+ )
174+ self ._set_if_not_none (
175+ attributes , GEN_AI_REQUEST_TOP_P , request_body .get ("top_p" )
176+ )
177+ self ._set_if_not_none (
178+ attributes ,
179+ GEN_AI_REQUEST_STOP_SEQUENCES ,
180+ request_body .get ("stop_sequences" ),
181+ )
182+
100183 @staticmethod
101184 def _set_if_not_none (attributes , key , value ):
102185 if value is not None :
@@ -122,6 +205,7 @@ def on_success(self, span: Span, result: dict[str, Any]):
122205 if not span .is_recording ():
123206 return
124207
208+ # Converse
125209 if usage := result .get ("usage" ):
126210 if input_tokens := usage .get ("inputTokens" ):
127211 span .set_attribute (
@@ -140,6 +224,98 @@ def on_success(self, span: Span, result: dict[str, Any]):
140224 [stop_reason ],
141225 )
142226
227+ model_id = self ._call_context .params .get (_MODEL_ID_KEY )
228+ if not model_id :
229+ return
230+
231+ # InvokeModel
232+ if "body" in result and isinstance (result ["body" ], StreamingBody ):
233+ original_body = None
234+ try :
235+ original_body = result ["body" ]
236+ body_content = original_body .read ()
237+
238+ # Use one stream for telemetry
239+ stream = io .BytesIO (body_content )
240+ telemetry_content = stream .read ()
241+ response_body = json .loads (telemetry_content .decode ("utf-8" ))
242+ if "amazon.titan" in model_id :
243+ self ._handle_amazon_titan_response (span , response_body )
244+ elif "amazon.nova" in model_id :
245+ self ._handle_amazon_nova_response (span , response_body )
246+ elif "anthropic.claude" in model_id :
247+ self ._handle_anthropic_claude_response (span , response_body )
248+ # Replenish stream for downstream application use
249+ new_stream = io .BytesIO (body_content )
250+ result ["body" ] = StreamingBody (new_stream , len (body_content ))
251+
252+ except json .JSONDecodeError :
253+ _logger .debug (
254+ "Error: Unable to parse the response body as JSON"
255+ )
256+ except Exception as exc : # pylint: disable=broad-exception-caught
257+ _logger .debug ("Error processing response: %s" , exc )
258+ finally :
259+ if original_body is not None :
260+ original_body .close ()
261+
262+ # pylint: disable=no-self-use
263+ def _handle_amazon_titan_response (
264+ self , span : Span , response_body : dict [str , Any ]
265+ ):
266+ if "inputTextTokenCount" in response_body :
267+ span .set_attribute (
268+ GEN_AI_USAGE_INPUT_TOKENS , response_body ["inputTextTokenCount" ]
269+ )
270+ if "results" in response_body and response_body ["results" ]:
271+ result = response_body ["results" ][0 ]
272+ if "tokenCount" in result :
273+ span .set_attribute (
274+ GEN_AI_USAGE_OUTPUT_TOKENS , result ["tokenCount" ]
275+ )
276+ if "completionReason" in result :
277+ span .set_attribute (
278+ GEN_AI_RESPONSE_FINISH_REASONS ,
279+ [result ["completionReason" ]],
280+ )
281+
282+ # pylint: disable=no-self-use
283+ def _handle_amazon_nova_response (
284+ self , span : Span , response_body : dict [str , Any ]
285+ ):
286+ if "usage" in response_body :
287+ usage = response_body ["usage" ]
288+ if "inputTokens" in usage :
289+ span .set_attribute (
290+ GEN_AI_USAGE_INPUT_TOKENS , usage ["inputTokens" ]
291+ )
292+ if "outputTokens" in usage :
293+ span .set_attribute (
294+ GEN_AI_USAGE_OUTPUT_TOKENS , usage ["outputTokens" ]
295+ )
296+ if "stopReason" in response_body :
297+ span .set_attribute (
298+ GEN_AI_RESPONSE_FINISH_REASONS , [response_body ["stopReason" ]]
299+ )
300+
301+ # pylint: disable=no-self-use
302+ def _handle_anthropic_claude_response (
303+ self , span : Span , response_body : dict [str , Any ]
304+ ):
305+ if usage := response_body .get ("usage" ):
306+ if "input_tokens" in usage :
307+ span .set_attribute (
308+ GEN_AI_USAGE_INPUT_TOKENS , usage ["input_tokens" ]
309+ )
310+ if "output_tokens" in usage :
311+ span .set_attribute (
312+ GEN_AI_USAGE_OUTPUT_TOKENS , usage ["output_tokens" ]
313+ )
314+ if "stop_reason" in response_body :
315+ span .set_attribute (
316+ GEN_AI_RESPONSE_FINISH_REASONS , [response_body ["stop_reason" ]]
317+ )
318+
143319 def on_error (self , span : Span , exception : _BotoClientErrorT ):
144320 if self ._call_context .operation not in self ._HANDLED_OPERATIONS :
145321 return
0 commit comments