Skip to content

Commit 6c0d5ca

Browse files
xrmxaryabharat
authored andcommitted
botocore: add basic tracing for Bedrock InvokeModelWithStreamResponse (open-telemetry#3206)
* Add basic tracing for InvokeModelWithResponseStream * Add changelog and please pylint
1 parent 9fc80e9 commit 6c0d5ca

11 files changed

+720
-9
lines changed

CHANGELOG.md

+2
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4747
([#3200](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3200))
4848
- `opentelemetry-opentelemetry-botocore` Add basic support for GenAI attributes for AWS Bedrock ConverseStream API
4949
([#3204](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3204))
50+
- `opentelemetry-opentelemetry-botocore` Add basic support for GenAI attributes for AWS Bedrock InvokeModelWithStreamResponse API
51+
([#3206](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3206))
5052
- `opentelemetry-instrumentation-pymssql` Add pymssql instrumentation
5153
([#394](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/394))
5254

instrumentation/opentelemetry-instrumentation-botocore/examples/bedrock-runtime/zero-code/README.rst

+1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Available examples
2020
- `converse.py` uses `bedrock-runtime` `Converse API <https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html>_`.
2121
- `converse_stream.py` uses `bedrock-runtime` `ConverseStream API <https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ConverseStream.html>_`.
2222
- `invoke_model.py` uses `bedrock-runtime` `InvokeModel API <https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_InvokeModel.html>_`.
23+
- `invoke_model_stream.py` uses `bedrock-runtime` `InvokeModelWithResponseStrea API <https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_InvokeModelWithResponseStream.html>_`.
2324

2425
Setup
2526
-----
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import json
2+
import os
3+
4+
import boto3
5+
6+
7+
def main():
8+
chat_model = os.getenv("CHAT_MODEL", "amazon.titan-text-lite-v1")
9+
prompt = "Write a short poem on OpenTelemetry."
10+
if "amazon.titan" in chat_model:
11+
body = {
12+
"inputText": prompt,
13+
"textGenerationConfig": {},
14+
}
15+
elif "amazon.nova" in chat_model:
16+
body = {
17+
"messages": [{"role": "user", "content": [{"text": prompt}]}],
18+
"schemaVersion": "messages-v1",
19+
}
20+
elif "anthropic.claude" in chat_model:
21+
body = {
22+
"messages": [
23+
{"role": "user", "content": [{"text": prompt, "type": "text"}]}
24+
],
25+
"anthropic_version": "bedrock-2023-05-31",
26+
"max_tokens": 200,
27+
}
28+
else:
29+
raise ValueError()
30+
client = boto3.client("bedrock-runtime")
31+
response = client.invoke_model_with_response_stream(
32+
modelId=chat_model,
33+
body=json.dumps(body),
34+
)
35+
36+
answer = ""
37+
for event in response["body"]:
38+
json_bytes = event.get("chunk", {}).get("bytes", b"")
39+
decoded = json_bytes.decode("utf-8")
40+
chunk = json.loads(decoded)
41+
if "outputText" in chunk:
42+
answer += chunk["outputText"]
43+
elif "completion" in chunk:
44+
answer += chunk["completion"]
45+
elif "contentBlockDelta" in chunk:
46+
answer += chunk["contentBlockDelta"]["delta"]["text"]
47+
print(answer)
48+
49+
50+
if __name__ == "__main__":
51+
main()

instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/extensions/bedrock.py

+25-2
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
from opentelemetry.instrumentation.botocore.extensions.bedrock_utils import (
3030
ConverseStreamWrapper,
31+
InvokeModelWithResponseStreamWrapper,
3132
)
3233
from opentelemetry.instrumentation.botocore.extensions.types import (
3334
_AttributeMapT,
@@ -66,8 +67,16 @@ class _BedrockRuntimeExtension(_AwsSdkExtension):
6667
Amazon Bedrock Runtime</a>.
6768
"""
6869

69-
_HANDLED_OPERATIONS = {"Converse", "ConverseStream", "InvokeModel"}
70-
_DONT_CLOSE_SPAN_ON_END_OPERATIONS = {"ConverseStream"}
70+
_HANDLED_OPERATIONS = {
71+
"Converse",
72+
"ConverseStream",
73+
"InvokeModel",
74+
"InvokeModelWithResponseStream",
75+
}
76+
_DONT_CLOSE_SPAN_ON_END_OPERATIONS = {
77+
"ConverseStream",
78+
"InvokeModelWithResponseStream",
79+
}
7180

7281
def should_end_span_on_exit(self):
7382
return (
@@ -288,6 +297,20 @@ def stream_done_callback(response):
288297
# InvokeModel
289298
if "body" in result and isinstance(result["body"], StreamingBody):
290299
self._invoke_model_on_success(span, result, model_id)
300+
return
301+
302+
# InvokeModelWithResponseStream
303+
if "body" in result and isinstance(result["body"], EventStream):
304+
305+
def invoke_model_stream_done_callback(response):
306+
# the callback gets data formatted as the simpler converse API
307+
self._converse_on_success(span, response)
308+
span.end()
309+
310+
result["body"] = InvokeModelWithResponseStreamWrapper(
311+
result["body"], invoke_model_stream_done_callback, model_id
312+
)
313+
return
291314

292315
# pylint: disable=no-self-use
293316
def _handle_amazon_titan_response(

instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/extensions/bedrock_utils.py

+139-3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818

1919
from __future__ import annotations
2020

21+
import json
22+
2123
from botocore.eventstream import EventStream
2224
from wrapt import ObjectProxy
2325

@@ -46,20 +48,21 @@ def __iter__(self):
4648
def _process_event(self, event):
4749
if "messageStart" in event:
4850
# {'messageStart': {'role': 'assistant'}}
49-
pass
51+
return
5052

5153
if "contentBlockDelta" in event:
5254
# {'contentBlockDelta': {'delta': {'text': "Hello"}, 'contentBlockIndex': 0}}
53-
pass
55+
return
5456

5557
if "contentBlockStop" in event:
5658
# {'contentBlockStop': {'contentBlockIndex': 0}}
57-
pass
59+
return
5860

5961
if "messageStop" in event:
6062
# {'messageStop': {'stopReason': 'end_turn'}}
6163
if stop_reason := event["messageStop"].get("stopReason"):
6264
self._response["stopReason"] = stop_reason
65+
return
6366

6467
if "metadata" in event:
6568
# {'metadata': {'usage': {'inputTokens': 12, 'outputTokens': 15, 'totalTokens': 27}, 'metrics': {'latencyMs': 2980}}}
@@ -72,3 +75,136 @@ def _process_event(self, event):
7275
self._response["usage"]["outputTokens"] = output_tokens
7376

7477
self._stream_done_callback(self._response)
78+
return
79+
80+
81+
# pylint: disable=abstract-method
82+
class InvokeModelWithResponseStreamWrapper(ObjectProxy):
83+
"""Wrapper for botocore.eventstream.EventStream"""
84+
85+
def __init__(
86+
self,
87+
stream: EventStream,
88+
stream_done_callback,
89+
model_id: str,
90+
):
91+
super().__init__(stream)
92+
93+
self._stream_done_callback = stream_done_callback
94+
self._model_id = model_id
95+
96+
# accumulating things in the same shape of the Converse API
97+
# {"usage": {"inputTokens": 0, "outputTokens": 0}, "stopReason": "finish"}
98+
self._response = {}
99+
100+
def __iter__(self):
101+
for event in self.__wrapped__:
102+
self._process_event(event)
103+
yield event
104+
105+
def _process_event(self, event):
106+
if "chunk" not in event:
107+
return
108+
109+
json_bytes = event["chunk"].get("bytes", b"")
110+
decoded = json_bytes.decode("utf-8")
111+
try:
112+
chunk = json.loads(decoded)
113+
except json.JSONDecodeError:
114+
return
115+
116+
if "amazon.titan" in self._model_id:
117+
self._process_amazon_titan_chunk(chunk)
118+
elif "amazon.nova" in self._model_id:
119+
self._process_amazon_nova_chunk(chunk)
120+
elif "anthropic.claude" in self._model_id:
121+
self._process_anthropic_claude_chunk(chunk)
122+
123+
def _process_invocation_metrics(self, invocation_metrics):
124+
self._response["usage"] = {}
125+
if input_tokens := invocation_metrics.get("inputTokenCount"):
126+
self._response["usage"]["inputTokens"] = input_tokens
127+
128+
if output_tokens := invocation_metrics.get("outputTokenCount"):
129+
self._response["usage"]["outputTokens"] = output_tokens
130+
131+
def _process_amazon_titan_chunk(self, chunk):
132+
if (stop_reason := chunk.get("completionReason")) is not None:
133+
self._response["stopReason"] = stop_reason
134+
135+
if invocation_metrics := chunk.get("amazon-bedrock-invocationMetrics"):
136+
# "amazon-bedrock-invocationMetrics":{
137+
# "inputTokenCount":9,"outputTokenCount":128,"invocationLatency":3569,"firstByteLatency":2180
138+
# }
139+
self._process_invocation_metrics(invocation_metrics)
140+
self._stream_done_callback(self._response)
141+
142+
def _process_amazon_nova_chunk(self, chunk):
143+
if "messageStart" in chunk:
144+
# {'messageStart': {'role': 'assistant'}}
145+
return
146+
147+
if "contentBlockDelta" in chunk:
148+
# {'contentBlockDelta': {'delta': {'text': "Hello"}, 'contentBlockIndex': 0}}
149+
return
150+
151+
if "contentBlockStop" in chunk:
152+
# {'contentBlockStop': {'contentBlockIndex': 0}}
153+
return
154+
155+
if "messageStop" in chunk:
156+
# {'messageStop': {'stopReason': 'end_turn'}}
157+
if stop_reason := chunk["messageStop"].get("stopReason"):
158+
self._response["stopReason"] = stop_reason
159+
return
160+
161+
if "metadata" in chunk:
162+
# {'metadata': {'usage': {'inputTokens': 8, 'outputTokens': 117}, 'metrics': {}, 'trace': {}}}
163+
if usage := chunk["metadata"].get("usage"):
164+
self._response["usage"] = {}
165+
if input_tokens := usage.get("inputTokens"):
166+
self._response["usage"]["inputTokens"] = input_tokens
167+
168+
if output_tokens := usage.get("outputTokens"):
169+
self._response["usage"]["outputTokens"] = output_tokens
170+
171+
self._stream_done_callback(self._response)
172+
return
173+
174+
def _process_anthropic_claude_chunk(self, chunk):
175+
# pylint: disable=too-many-return-statements
176+
if not (message_type := chunk.get("type")):
177+
return
178+
179+
if message_type == "message_start":
180+
# {'type': 'message_start', 'message': {'id': 'id', 'type': 'message', 'role': 'assistant', 'model': 'claude-2.0', 'content': [], 'stop_reason': None, 'stop_sequence': None, 'usage': {'input_tokens': 18, 'output_tokens': 1}}}
181+
return
182+
183+
if message_type == "content_block_start":
184+
# {'type': 'content_block_start', 'index': 0, 'content_block': {'type': 'text', 'text': ''}}
185+
return
186+
187+
if message_type == "content_block_delta":
188+
# {'type': 'content_block_delta', 'index': 0, 'delta': {'type': 'text_delta', 'text': 'Here'}}
189+
return
190+
191+
if message_type == "content_block_stop":
192+
# {'type': 'content_block_stop', 'index': 0}
193+
return
194+
195+
if message_type == "message_delta":
196+
# {'type': 'message_delta', 'delta': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'usage': {'output_tokens': 123}}
197+
if (
198+
stop_reason := chunk.get("delta", {}).get("stop_reason")
199+
) is not None:
200+
self._response["stopReason"] = stop_reason
201+
return
202+
203+
if message_type == "message_stop":
204+
# {'type': 'message_stop', 'amazon-bedrock-invocationMetrics': {'inputTokenCount': 18, 'outputTokenCount': 123, 'invocationLatency': 5250, 'firstByteLatency': 290}}
205+
if invocation_metrics := chunk.get(
206+
"amazon-bedrock-invocationMetrics"
207+
):
208+
self._process_invocation_metrics(invocation_metrics)
209+
self._stream_done_callback(self._response)
210+
return

instrumentation/opentelemetry-instrumentation-botocore/tests/bedrock_utils.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def assert_converse_completion_attributes(
128128
)
129129

130130

131-
def assert_converse_stream_completion_attributes(
131+
def assert_stream_completion_attributes(
132132
span: ReadableSpan,
133133
request_model: str,
134134
input_tokens: int | None = None,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
interactions:
2+
- request:
3+
body: null
4+
headers:
5+
Content-Length:
6+
- '0'
7+
User-Agent:
8+
- !!binary |
9+
Qm90bzMvMS4zNS41NiBtZC9Cb3RvY29yZSMxLjM1LjU2IHVhLzIuMCBvcy9saW51eCM2LjEuMC0x
10+
MDM0LW9lbSBtZC9hcmNoI3g4Nl82NCBsYW5nL3B5dGhvbiMzLjEwLjEyIG1kL3B5aW1wbCNDUHl0
11+
aG9uIGNmZy9yZXRyeS1tb2RlI2xlZ2FjeSBCb3RvY29yZS8xLjM1LjU2
12+
X-Amz-Date:
13+
- !!binary |
14+
MjAyNTAxMjRUMTM0NDM5Wg==
15+
X-Amz-Security-Token:
16+
- test_aws_security_token
17+
X-Amzn-Trace-Id:
18+
- !!binary |
19+
Um9vdD0xLTFlMjljM2Y1LTU2MzZhOWI4MmViYTYxOTFiOTcwOTI2YTtQYXJlbnQ9NzA1NzBlZjUy
20+
YzJkZjliYjtTYW1wbGVkPTE=
21+
amz-sdk-invocation-id:
22+
- !!binary |
23+
ZDg2MjFlMzAtNTk3Yi00ZWM3LWJlNGEtMThkMDQwZTRhMzcw
24+
amz-sdk-request:
25+
- !!binary |
26+
YXR0ZW1wdD0x
27+
authorization:
28+
- Bearer test_aws_authorization
29+
method: POST
30+
uri: https://bedrock-runtime.us-east-1.amazonaws.com/model/does-not-exist/invoke-with-response-stream
31+
response:
32+
body:
33+
string: '{"message":"The provided model identifier is invalid."}'
34+
headers:
35+
Connection:
36+
- keep-alive
37+
Content-Length:
38+
- '55'
39+
Content-Type:
40+
- application/json
41+
Date:
42+
- Fri, 24 Jan 2025 13:44:40 GMT
43+
Set-Cookie: test_set_cookie
44+
x-amzn-ErrorType:
45+
- ValidationException:http://internal.amazon.com/coral/com.amazon.bedrock/
46+
x-amzn-RequestId:
47+
- 6460a108-875d-4e26-bcdf-f03c4c815f74
48+
status:
49+
code: 400
50+
message: Bad Request
51+
version: 1

0 commit comments

Comments
 (0)