opentelemetry-instrumentation-botocore 0.50b0__py3-none-any.whl → 0.52b0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,756 @@
1
+ # Copyright The OpenTelemetry Authors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # Includes work from:
16
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
17
+ # SPDX-License-Identifier: Apache-2.0
18
+
19
+ from __future__ import annotations
20
+
21
+ import io
22
+ import json
23
+ import logging
24
+ from timeit import default_timer
25
+ from typing import Any
26
+
27
+ from botocore.eventstream import EventStream
28
+ from botocore.response import StreamingBody
29
+
30
+ from opentelemetry.instrumentation.botocore.extensions.bedrock_utils import (
31
+ ConverseStreamWrapper,
32
+ InvokeModelWithResponseStreamWrapper,
33
+ _Choice,
34
+ genai_capture_message_content,
35
+ message_to_event,
36
+ )
37
+ from opentelemetry.instrumentation.botocore.extensions.types import (
38
+ _AttributeMapT,
39
+ _AwsSdkExtension,
40
+ _BotoClientErrorT,
41
+ _BotocoreInstrumentorContext,
42
+ )
43
+ from opentelemetry.metrics import Instrument, Meter
44
+ from opentelemetry.semconv._incubating.attributes.error_attributes import (
45
+ ERROR_TYPE,
46
+ )
47
+ from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import (
48
+ GEN_AI_OPERATION_NAME,
49
+ GEN_AI_REQUEST_MAX_TOKENS,
50
+ GEN_AI_REQUEST_MODEL,
51
+ GEN_AI_REQUEST_STOP_SEQUENCES,
52
+ GEN_AI_REQUEST_TEMPERATURE,
53
+ GEN_AI_REQUEST_TOP_P,
54
+ GEN_AI_RESPONSE_FINISH_REASONS,
55
+ GEN_AI_SYSTEM,
56
+ GEN_AI_TOKEN_TYPE,
57
+ GEN_AI_USAGE_INPUT_TOKENS,
58
+ GEN_AI_USAGE_OUTPUT_TOKENS,
59
+ GenAiOperationNameValues,
60
+ GenAiSystemValues,
61
+ GenAiTokenTypeValues,
62
+ )
63
+ from opentelemetry.semconv._incubating.metrics.gen_ai_metrics import (
64
+ GEN_AI_CLIENT_OPERATION_DURATION,
65
+ GEN_AI_CLIENT_TOKEN_USAGE,
66
+ )
67
+ from opentelemetry.trace.span import Span
68
+ from opentelemetry.trace.status import Status, StatusCode
69
+
70
+ _logger = logging.getLogger(__name__)
71
+
72
+ _GEN_AI_CLIENT_OPERATION_DURATION_BUCKETS = [
73
+ 0.01,
74
+ 0.02,
75
+ 0.04,
76
+ 0.08,
77
+ 0.16,
78
+ 0.32,
79
+ 0.64,
80
+ 1.28,
81
+ 2.56,
82
+ 5.12,
83
+ 10.24,
84
+ 20.48,
85
+ 40.96,
86
+ 81.92,
87
+ ]
88
+
89
+ _GEN_AI_CLIENT_TOKEN_USAGE_BUCKETS = [
90
+ 1,
91
+ 4,
92
+ 16,
93
+ 64,
94
+ 256,
95
+ 1024,
96
+ 4096,
97
+ 16384,
98
+ 65536,
99
+ 262144,
100
+ 1048576,
101
+ 4194304,
102
+ 16777216,
103
+ 67108864,
104
+ ]
105
+
106
+ _MODEL_ID_KEY: str = "modelId"
107
+
108
+
109
+ class _BedrockRuntimeExtension(_AwsSdkExtension):
110
+ """
111
+ This class is an extension for <a
112
+ href="https://docs.aws.amazon.com/bedrock/latest/APIReference/API_Operations_Amazon_Bedrock_Runtime.html">
113
+ Amazon Bedrock Runtime</a>.
114
+ """
115
+
116
+ _HANDLED_OPERATIONS = {
117
+ "Converse",
118
+ "ConverseStream",
119
+ "InvokeModel",
120
+ "InvokeModelWithResponseStream",
121
+ }
122
+ _DONT_CLOSE_SPAN_ON_END_OPERATIONS = {
123
+ "ConverseStream",
124
+ "InvokeModelWithResponseStream",
125
+ }
126
+
127
+ def should_end_span_on_exit(self):
128
+ return (
129
+ self._call_context.operation
130
+ not in self._DONT_CLOSE_SPAN_ON_END_OPERATIONS
131
+ )
132
+
133
+ def setup_metrics(self, meter: Meter, metrics: dict[str, Instrument]):
134
+ metrics[GEN_AI_CLIENT_OPERATION_DURATION] = meter.create_histogram(
135
+ name=GEN_AI_CLIENT_OPERATION_DURATION,
136
+ description="GenAI operation duration",
137
+ unit="s",
138
+ explicit_bucket_boundaries_advisory=_GEN_AI_CLIENT_OPERATION_DURATION_BUCKETS,
139
+ )
140
+ metrics[GEN_AI_CLIENT_TOKEN_USAGE] = meter.create_histogram(
141
+ name=GEN_AI_CLIENT_TOKEN_USAGE,
142
+ description="Measures number of input and output tokens used",
143
+ unit="{token}",
144
+ explicit_bucket_boundaries_advisory=_GEN_AI_CLIENT_TOKEN_USAGE_BUCKETS,
145
+ )
146
+
147
+ def _extract_metrics_attributes(self) -> _AttributeMapT:
148
+ attributes = {GEN_AI_SYSTEM: GenAiSystemValues.AWS_BEDROCK.value}
149
+
150
+ model_id = self._call_context.params.get(_MODEL_ID_KEY)
151
+ if not model_id:
152
+ return attributes
153
+
154
+ attributes[GEN_AI_REQUEST_MODEL] = model_id
155
+
156
+ # titan in invoke model is a text completion one
157
+ if "body" in self._call_context.params and "amazon.titan" in model_id:
158
+ attributes[GEN_AI_OPERATION_NAME] = (
159
+ GenAiOperationNameValues.TEXT_COMPLETION.value
160
+ )
161
+ else:
162
+ attributes[GEN_AI_OPERATION_NAME] = (
163
+ GenAiOperationNameValues.CHAT.value
164
+ )
165
+ return attributes
166
+
167
+ def extract_attributes(self, attributes: _AttributeMapT):
168
+ if self._call_context.operation not in self._HANDLED_OPERATIONS:
169
+ return
170
+
171
+ attributes[GEN_AI_SYSTEM] = GenAiSystemValues.AWS_BEDROCK.value
172
+
173
+ model_id = self._call_context.params.get(_MODEL_ID_KEY)
174
+ if model_id:
175
+ attributes[GEN_AI_REQUEST_MODEL] = model_id
176
+ attributes[GEN_AI_OPERATION_NAME] = (
177
+ GenAiOperationNameValues.CHAT.value
178
+ )
179
+
180
+ # Converse / ConverseStream
181
+ if inference_config := self._call_context.params.get(
182
+ "inferenceConfig"
183
+ ):
184
+ self._set_if_not_none(
185
+ attributes,
186
+ GEN_AI_REQUEST_TEMPERATURE,
187
+ inference_config.get("temperature"),
188
+ )
189
+ self._set_if_not_none(
190
+ attributes,
191
+ GEN_AI_REQUEST_TOP_P,
192
+ inference_config.get("topP"),
193
+ )
194
+ self._set_if_not_none(
195
+ attributes,
196
+ GEN_AI_REQUEST_MAX_TOKENS,
197
+ inference_config.get("maxTokens"),
198
+ )
199
+ self._set_if_not_none(
200
+ attributes,
201
+ GEN_AI_REQUEST_STOP_SEQUENCES,
202
+ inference_config.get("stopSequences"),
203
+ )
204
+
205
+ # InvokeModel
206
+ # Get the request body if it exists
207
+ body = self._call_context.params.get("body")
208
+ if body:
209
+ try:
210
+ request_body = json.loads(body)
211
+
212
+ if "amazon.titan" in model_id:
213
+ # titan interface is a text completion one
214
+ attributes[GEN_AI_OPERATION_NAME] = (
215
+ GenAiOperationNameValues.TEXT_COMPLETION.value
216
+ )
217
+ self._extract_titan_attributes(
218
+ attributes, request_body
219
+ )
220
+ elif "amazon.nova" in model_id:
221
+ self._extract_nova_attributes(attributes, request_body)
222
+ elif "anthropic.claude" in model_id:
223
+ self._extract_claude_attributes(
224
+ attributes, request_body
225
+ )
226
+ except json.JSONDecodeError:
227
+ _logger.debug("Error: Unable to parse the body as JSON")
228
+
229
+ def _extract_titan_attributes(self, attributes, request_body):
230
+ config = request_body.get("textGenerationConfig", {})
231
+ self._set_if_not_none(
232
+ attributes, GEN_AI_REQUEST_TEMPERATURE, config.get("temperature")
233
+ )
234
+ self._set_if_not_none(
235
+ attributes, GEN_AI_REQUEST_TOP_P, config.get("topP")
236
+ )
237
+ self._set_if_not_none(
238
+ attributes, GEN_AI_REQUEST_MAX_TOKENS, config.get("maxTokenCount")
239
+ )
240
+ self._set_if_not_none(
241
+ attributes,
242
+ GEN_AI_REQUEST_STOP_SEQUENCES,
243
+ config.get("stopSequences"),
244
+ )
245
+
246
+ def _extract_nova_attributes(self, attributes, request_body):
247
+ config = request_body.get("inferenceConfig", {})
248
+ self._set_if_not_none(
249
+ attributes, GEN_AI_REQUEST_TEMPERATURE, config.get("temperature")
250
+ )
251
+ self._set_if_not_none(
252
+ attributes, GEN_AI_REQUEST_TOP_P, config.get("topP")
253
+ )
254
+ self._set_if_not_none(
255
+ attributes, GEN_AI_REQUEST_MAX_TOKENS, config.get("max_new_tokens")
256
+ )
257
+ self._set_if_not_none(
258
+ attributes,
259
+ GEN_AI_REQUEST_STOP_SEQUENCES,
260
+ config.get("stopSequences"),
261
+ )
262
+
263
+ def _extract_claude_attributes(self, attributes, request_body):
264
+ self._set_if_not_none(
265
+ attributes,
266
+ GEN_AI_REQUEST_MAX_TOKENS,
267
+ request_body.get("max_tokens"),
268
+ )
269
+ self._set_if_not_none(
270
+ attributes,
271
+ GEN_AI_REQUEST_TEMPERATURE,
272
+ request_body.get("temperature"),
273
+ )
274
+ self._set_if_not_none(
275
+ attributes, GEN_AI_REQUEST_TOP_P, request_body.get("top_p")
276
+ )
277
+ self._set_if_not_none(
278
+ attributes,
279
+ GEN_AI_REQUEST_STOP_SEQUENCES,
280
+ request_body.get("stop_sequences"),
281
+ )
282
+
283
+ @staticmethod
284
+ def _set_if_not_none(attributes, key, value):
285
+ if value is not None:
286
+ attributes[key] = value
287
+
288
+ def _get_request_messages(self):
289
+ """Extracts and normalize system and user / assistant messages"""
290
+ input_text = None
291
+ if system := self._call_context.params.get("system", []):
292
+ system_messages = [{"role": "system", "content": system}]
293
+ else:
294
+ system_messages = []
295
+
296
+ if not (messages := self._call_context.params.get("messages", [])):
297
+ if body := self._call_context.params.get("body"):
298
+ decoded_body = json.loads(body)
299
+ if system := decoded_body.get("system"):
300
+ if isinstance(system, str):
301
+ content = [{"text": system}]
302
+ else:
303
+ content = system
304
+ system_messages = [{"role": "system", "content": content}]
305
+
306
+ messages = decoded_body.get("messages", [])
307
+ if not messages:
308
+ # transform old school amazon titan invokeModel api to messages
309
+ if input_text := decoded_body.get("inputText"):
310
+ messages = [
311
+ {"role": "user", "content": [{"text": input_text}]}
312
+ ]
313
+
314
+ return system_messages + messages
315
+
316
+ def before_service_call(
317
+ self, span: Span, instrumentor_context: _BotocoreInstrumentorContext
318
+ ):
319
+ if self._call_context.operation not in self._HANDLED_OPERATIONS:
320
+ return
321
+
322
+ capture_content = genai_capture_message_content()
323
+
324
+ messages = self._get_request_messages()
325
+ for message in messages:
326
+ event_logger = instrumentor_context.event_logger
327
+ for event in message_to_event(message, capture_content):
328
+ event_logger.emit(event)
329
+
330
+ if span.is_recording():
331
+ operation_name = span.attributes.get(GEN_AI_OPERATION_NAME, "")
332
+ request_model = span.attributes.get(GEN_AI_REQUEST_MODEL, "")
333
+ # avoid setting to an empty string if are not available
334
+ if operation_name and request_model:
335
+ span.update_name(f"{operation_name} {request_model}")
336
+
337
+ # this is used to calculate the operation duration metric, duration may be skewed by request_hook
338
+ # pylint: disable=attribute-defined-outside-init
339
+ self._operation_start = default_timer()
340
+
341
+ # pylint: disable=no-self-use,too-many-locals
342
+ def _converse_on_success(
343
+ self,
344
+ span: Span,
345
+ result: dict[str, Any],
346
+ instrumentor_context: _BotocoreInstrumentorContext,
347
+ capture_content,
348
+ ):
349
+ if span.is_recording():
350
+ if usage := result.get("usage"):
351
+ if input_tokens := usage.get("inputTokens"):
352
+ span.set_attribute(
353
+ GEN_AI_USAGE_INPUT_TOKENS,
354
+ input_tokens,
355
+ )
356
+ if output_tokens := usage.get("outputTokens"):
357
+ span.set_attribute(
358
+ GEN_AI_USAGE_OUTPUT_TOKENS,
359
+ output_tokens,
360
+ )
361
+
362
+ if stop_reason := result.get("stopReason"):
363
+ span.set_attribute(
364
+ GEN_AI_RESPONSE_FINISH_REASONS,
365
+ [stop_reason],
366
+ )
367
+
368
+ event_logger = instrumentor_context.event_logger
369
+ choice = _Choice.from_converse(result, capture_content)
370
+ # this path is used by streaming apis, in that case we are already out of the span
371
+ # context so need to add the span context manually
372
+ span_ctx = span.get_span_context()
373
+ event_logger.emit(
374
+ choice.to_choice_event(
375
+ trace_id=span_ctx.trace_id,
376
+ span_id=span_ctx.span_id,
377
+ trace_flags=span_ctx.trace_flags,
378
+ )
379
+ )
380
+
381
+ metrics = instrumentor_context.metrics
382
+ metrics_attributes = self._extract_metrics_attributes()
383
+ if operation_duration_histogram := metrics.get(
384
+ GEN_AI_CLIENT_OPERATION_DURATION
385
+ ):
386
+ duration = max((default_timer() - self._operation_start), 0)
387
+ operation_duration_histogram.record(
388
+ duration,
389
+ attributes=metrics_attributes,
390
+ )
391
+
392
+ if token_usage_histogram := metrics.get(GEN_AI_CLIENT_TOKEN_USAGE):
393
+ if usage := result.get("usage"):
394
+ if input_tokens := usage.get("inputTokens"):
395
+ input_attributes = {
396
+ **metrics_attributes,
397
+ GEN_AI_TOKEN_TYPE: GenAiTokenTypeValues.INPUT.value,
398
+ }
399
+ token_usage_histogram.record(
400
+ input_tokens, input_attributes
401
+ )
402
+
403
+ if output_tokens := usage.get("outputTokens"):
404
+ output_attributes = {
405
+ **metrics_attributes,
406
+ GEN_AI_TOKEN_TYPE: GenAiTokenTypeValues.COMPLETION.value,
407
+ }
408
+ token_usage_histogram.record(
409
+ output_tokens, output_attributes
410
+ )
411
+
412
+ def _invoke_model_on_success(
413
+ self,
414
+ span: Span,
415
+ result: dict[str, Any],
416
+ model_id: str,
417
+ instrumentor_context: _BotocoreInstrumentorContext,
418
+ capture_content,
419
+ ):
420
+ original_body = None
421
+ try:
422
+ original_body = result["body"]
423
+ body_content = original_body.read()
424
+
425
+ # Replenish stream for downstream application use
426
+ new_stream = io.BytesIO(body_content)
427
+ result["body"] = StreamingBody(new_stream, len(body_content))
428
+
429
+ response_body = json.loads(body_content.decode("utf-8"))
430
+ if "amazon.titan" in model_id:
431
+ self._handle_amazon_titan_response(
432
+ span, response_body, instrumentor_context, capture_content
433
+ )
434
+ elif "amazon.nova" in model_id:
435
+ self._handle_amazon_nova_response(
436
+ span, response_body, instrumentor_context, capture_content
437
+ )
438
+ elif "anthropic.claude" in model_id:
439
+ self._handle_anthropic_claude_response(
440
+ span, response_body, instrumentor_context, capture_content
441
+ )
442
+ except json.JSONDecodeError:
443
+ _logger.debug("Error: Unable to parse the response body as JSON")
444
+ except Exception as exc: # pylint: disable=broad-exception-caught
445
+ _logger.debug("Error processing response: %s", exc)
446
+ finally:
447
+ if original_body is not None:
448
+ original_body.close()
449
+
450
+ def _on_stream_error_callback(
451
+ self,
452
+ span: Span,
453
+ exception,
454
+ instrumentor_context: _BotocoreInstrumentorContext,
455
+ ):
456
+ span.set_status(Status(StatusCode.ERROR, str(exception)))
457
+ if span.is_recording():
458
+ span.set_attribute(ERROR_TYPE, type(exception).__qualname__)
459
+ span.end()
460
+
461
+ metrics = instrumentor_context.metrics
462
+ metrics_attributes = {
463
+ **self._extract_metrics_attributes(),
464
+ ERROR_TYPE: type(exception).__qualname__,
465
+ }
466
+ if operation_duration_histogram := metrics.get(
467
+ GEN_AI_CLIENT_OPERATION_DURATION
468
+ ):
469
+ duration = max((default_timer() - self._operation_start), 0)
470
+ operation_duration_histogram.record(
471
+ duration,
472
+ attributes=metrics_attributes,
473
+ )
474
+
475
+ def on_success(
476
+ self,
477
+ span: Span,
478
+ result: dict[str, Any],
479
+ instrumentor_context: _BotocoreInstrumentorContext,
480
+ ):
481
+ if self._call_context.operation not in self._HANDLED_OPERATIONS:
482
+ return
483
+
484
+ capture_content = genai_capture_message_content()
485
+
486
+ if self._call_context.operation == "ConverseStream":
487
+ if "stream" in result and isinstance(
488
+ result["stream"], EventStream
489
+ ):
490
+
491
+ def stream_done_callback(response):
492
+ self._converse_on_success(
493
+ span, response, instrumentor_context, capture_content
494
+ )
495
+ span.end()
496
+
497
+ def stream_error_callback(exception):
498
+ self._on_stream_error_callback(
499
+ span, exception, instrumentor_context
500
+ )
501
+
502
+ result["stream"] = ConverseStreamWrapper(
503
+ result["stream"],
504
+ stream_done_callback,
505
+ stream_error_callback,
506
+ )
507
+ return
508
+ elif self._call_context.operation == "Converse":
509
+ self._converse_on_success(
510
+ span, result, instrumentor_context, capture_content
511
+ )
512
+
513
+ model_id = self._call_context.params.get(_MODEL_ID_KEY)
514
+ if not model_id:
515
+ return
516
+
517
+ if self._call_context.operation == "InvokeModel":
518
+ if "body" in result and isinstance(result["body"], StreamingBody):
519
+ self._invoke_model_on_success(
520
+ span,
521
+ result,
522
+ model_id,
523
+ instrumentor_context,
524
+ capture_content,
525
+ )
526
+ return
527
+ elif self._call_context.operation == "InvokeModelWithResponseStream":
528
+ if "body" in result and isinstance(result["body"], EventStream):
529
+
530
+ def invoke_model_stream_done_callback(response):
531
+ # the callback gets data formatted as the simpler converse API
532
+ self._converse_on_success(
533
+ span, response, instrumentor_context, capture_content
534
+ )
535
+ span.end()
536
+
537
+ def invoke_model_stream_error_callback(exception):
538
+ self._on_stream_error_callback(
539
+ span, exception, instrumentor_context
540
+ )
541
+
542
+ result["body"] = InvokeModelWithResponseStreamWrapper(
543
+ result["body"],
544
+ invoke_model_stream_done_callback,
545
+ invoke_model_stream_error_callback,
546
+ model_id,
547
+ )
548
+ return
549
+
550
+ # pylint: disable=no-self-use,too-many-locals
551
+ def _handle_amazon_titan_response(
552
+ self,
553
+ span: Span,
554
+ response_body: dict[str, Any],
555
+ instrumentor_context: _BotocoreInstrumentorContext,
556
+ capture_content: bool,
557
+ ):
558
+ if "inputTextTokenCount" in response_body:
559
+ span.set_attribute(
560
+ GEN_AI_USAGE_INPUT_TOKENS, response_body["inputTextTokenCount"]
561
+ )
562
+ if "results" in response_body and response_body["results"]:
563
+ result = response_body["results"][0]
564
+ if "tokenCount" in result:
565
+ span.set_attribute(
566
+ GEN_AI_USAGE_OUTPUT_TOKENS, result["tokenCount"]
567
+ )
568
+ if "completionReason" in result:
569
+ span.set_attribute(
570
+ GEN_AI_RESPONSE_FINISH_REASONS,
571
+ [result["completionReason"]],
572
+ )
573
+
574
+ event_logger = instrumentor_context.event_logger
575
+ choice = _Choice.from_invoke_amazon_titan(
576
+ response_body, capture_content
577
+ )
578
+ event_logger.emit(choice.to_choice_event())
579
+
580
+ metrics = instrumentor_context.metrics
581
+ metrics_attributes = self._extract_metrics_attributes()
582
+ if operation_duration_histogram := metrics.get(
583
+ GEN_AI_CLIENT_OPERATION_DURATION
584
+ ):
585
+ duration = max((default_timer() - self._operation_start), 0)
586
+ operation_duration_histogram.record(
587
+ duration,
588
+ attributes=metrics_attributes,
589
+ )
590
+
591
+ if token_usage_histogram := metrics.get(GEN_AI_CLIENT_TOKEN_USAGE):
592
+ if input_tokens := response_body.get("inputTextTokenCount"):
593
+ input_attributes = {
594
+ **metrics_attributes,
595
+ GEN_AI_TOKEN_TYPE: GenAiTokenTypeValues.INPUT.value,
596
+ }
597
+ token_usage_histogram.record(
598
+ input_tokens, input_attributes
599
+ )
600
+
601
+ if results := response_body.get("results"):
602
+ if output_tokens := results[0].get("tokenCount"):
603
+ output_attributes = {
604
+ **metrics_attributes,
605
+ GEN_AI_TOKEN_TYPE: GenAiTokenTypeValues.COMPLETION.value,
606
+ }
607
+ token_usage_histogram.record(
608
+ output_tokens, output_attributes
609
+ )
610
+
611
+ # pylint: disable=no-self-use,too-many-locals
612
+ def _handle_amazon_nova_response(
613
+ self,
614
+ span: Span,
615
+ response_body: dict[str, Any],
616
+ instrumentor_context: _BotocoreInstrumentorContext,
617
+ capture_content: bool,
618
+ ):
619
+ if "usage" in response_body:
620
+ usage = response_body["usage"]
621
+ if "inputTokens" in usage:
622
+ span.set_attribute(
623
+ GEN_AI_USAGE_INPUT_TOKENS, usage["inputTokens"]
624
+ )
625
+ if "outputTokens" in usage:
626
+ span.set_attribute(
627
+ GEN_AI_USAGE_OUTPUT_TOKENS, usage["outputTokens"]
628
+ )
629
+ if "stopReason" in response_body:
630
+ span.set_attribute(
631
+ GEN_AI_RESPONSE_FINISH_REASONS, [response_body["stopReason"]]
632
+ )
633
+
634
+ event_logger = instrumentor_context.event_logger
635
+ choice = _Choice.from_converse(response_body, capture_content)
636
+ event_logger.emit(choice.to_choice_event())
637
+
638
+ metrics = instrumentor_context.metrics
639
+ metrics_attributes = self._extract_metrics_attributes()
640
+ if operation_duration_histogram := metrics.get(
641
+ GEN_AI_CLIENT_OPERATION_DURATION
642
+ ):
643
+ duration = max((default_timer() - self._operation_start), 0)
644
+ operation_duration_histogram.record(
645
+ duration,
646
+ attributes=metrics_attributes,
647
+ )
648
+
649
+ if token_usage_histogram := metrics.get(GEN_AI_CLIENT_TOKEN_USAGE):
650
+ if usage := response_body.get("usage"):
651
+ if input_tokens := usage.get("inputTokens"):
652
+ input_attributes = {
653
+ **metrics_attributes,
654
+ GEN_AI_TOKEN_TYPE: GenAiTokenTypeValues.INPUT.value,
655
+ }
656
+ token_usage_histogram.record(
657
+ input_tokens, input_attributes
658
+ )
659
+
660
+ if output_tokens := usage.get("outputTokens"):
661
+ output_attributes = {
662
+ **metrics_attributes,
663
+ GEN_AI_TOKEN_TYPE: GenAiTokenTypeValues.COMPLETION.value,
664
+ }
665
+ token_usage_histogram.record(
666
+ output_tokens, output_attributes
667
+ )
668
+
669
+ # pylint: disable=no-self-use
670
+ def _handle_anthropic_claude_response(
671
+ self,
672
+ span: Span,
673
+ response_body: dict[str, Any],
674
+ instrumentor_context: _BotocoreInstrumentorContext,
675
+ capture_content: bool,
676
+ ):
677
+ if usage := response_body.get("usage"):
678
+ if "input_tokens" in usage:
679
+ span.set_attribute(
680
+ GEN_AI_USAGE_INPUT_TOKENS, usage["input_tokens"]
681
+ )
682
+ if "output_tokens" in usage:
683
+ span.set_attribute(
684
+ GEN_AI_USAGE_OUTPUT_TOKENS, usage["output_tokens"]
685
+ )
686
+ if "stop_reason" in response_body:
687
+ span.set_attribute(
688
+ GEN_AI_RESPONSE_FINISH_REASONS, [response_body["stop_reason"]]
689
+ )
690
+
691
+ event_logger = instrumentor_context.event_logger
692
+ choice = _Choice.from_invoke_anthropic_claude(
693
+ response_body, capture_content
694
+ )
695
+ event_logger.emit(choice.to_choice_event())
696
+
697
+ metrics = instrumentor_context.metrics
698
+ metrics_attributes = self._extract_metrics_attributes()
699
+ if operation_duration_histogram := metrics.get(
700
+ GEN_AI_CLIENT_OPERATION_DURATION
701
+ ):
702
+ duration = max((default_timer() - self._operation_start), 0)
703
+ operation_duration_histogram.record(
704
+ duration,
705
+ attributes=metrics_attributes,
706
+ )
707
+
708
+ if token_usage_histogram := metrics.get(GEN_AI_CLIENT_TOKEN_USAGE):
709
+ if usage := response_body.get("usage"):
710
+ if input_tokens := usage.get("input_tokens"):
711
+ input_attributes = {
712
+ **metrics_attributes,
713
+ GEN_AI_TOKEN_TYPE: GenAiTokenTypeValues.INPUT.value,
714
+ }
715
+ token_usage_histogram.record(
716
+ input_tokens, input_attributes
717
+ )
718
+
719
+ if output_tokens := usage.get("output_tokens"):
720
+ output_attributes = {
721
+ **metrics_attributes,
722
+ GEN_AI_TOKEN_TYPE: GenAiTokenTypeValues.COMPLETION.value,
723
+ }
724
+ token_usage_histogram.record(
725
+ output_tokens, output_attributes
726
+ )
727
+
728
+ def on_error(
729
+ self,
730
+ span: Span,
731
+ exception: _BotoClientErrorT,
732
+ instrumentor_context: _BotocoreInstrumentorContext,
733
+ ):
734
+ if self._call_context.operation not in self._HANDLED_OPERATIONS:
735
+ return
736
+
737
+ span.set_status(Status(StatusCode.ERROR, str(exception)))
738
+ if span.is_recording():
739
+ span.set_attribute(ERROR_TYPE, type(exception).__qualname__)
740
+
741
+ if not self.should_end_span_on_exit():
742
+ span.end()
743
+
744
+ metrics = instrumentor_context.metrics
745
+ metrics_attributes = {
746
+ **self._extract_metrics_attributes(),
747
+ ERROR_TYPE: type(exception).__qualname__,
748
+ }
749
+ if operation_duration_histogram := metrics.get(
750
+ GEN_AI_CLIENT_OPERATION_DURATION
751
+ ):
752
+ duration = max((default_timer() - self._operation_start), 0)
753
+ operation_duration_histogram.record(
754
+ duration,
755
+ attributes=metrics_attributes,
756
+ )