telemetry-dev-bedrock 0.1.0__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,7 @@
1
+ from __future__ import annotations
2
+
3
+ from ._instrument import instrument_bedrock, uninstrument_bedrock, wrap_bedrock
4
+
5
+ __version__ = "0.1.0"
6
+
7
+ __all__ = ["__version__", "instrument_bedrock", "uninstrument_bedrock", "wrap_bedrock"]
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ PROVIDER = "amazon-bedrock"
7
+
8
+
9
+ def clean(fields: dict[str, Any]) -> dict[str, Any]:
10
+ return {key: value for key, value in fields.items() if value is not None}
11
+
12
+
13
+ def metadata_fields(response: dict[str, Any] | BaseException | None) -> dict[str, Any]:
14
+ metadata: dict[str, Any] | None = None
15
+ if isinstance(response, dict) and isinstance(response.get("ResponseMetadata"), dict):
16
+ metadata = response["ResponseMetadata"]
17
+ else:
18
+ error_response = getattr(response, "response", None)
19
+ if isinstance(error_response, dict) and isinstance(
20
+ error_response.get("ResponseMetadata"), dict
21
+ ):
22
+ metadata = error_response["ResponseMetadata"]
23
+ if metadata is None:
24
+ return {}
25
+ retry_attempts = metadata.get("RetryAttempts")
26
+ attempts = retry_attempts + 1 if isinstance(retry_attempts, int) else None
27
+ total_retry_delay = metadata.get("TotalRetryDelay")
28
+ if total_retry_delay is None:
29
+ total_retry_delay = metadata.get("totalRetryDelay")
30
+ attributes = clean(
31
+ {
32
+ "aws.http.status_code": metadata.get("HTTPStatusCode"),
33
+ "aws.request.attempts": attempts if attempts and attempts > 1 else None,
34
+ "aws.request.total_retry_delay_ms": total_retry_delay,
35
+ }
36
+ )
37
+ return clean(
38
+ {
39
+ "response_id": metadata.get("RequestId"),
40
+ "attributes": attributes or None,
41
+ }
42
+ )
43
+
44
+
45
+ def error_fields(error: BaseException) -> dict[str, Any]:
46
+ fields = metadata_fields(error)
47
+ response = getattr(error, "response", None)
48
+ code = None
49
+ if isinstance(response, dict) and isinstance(response.get("Error"), dict):
50
+ code = response["Error"].get("Code")
51
+ fields["error"] = error
52
+ if code:
53
+ attrs = dict(fields.get("attributes") or {})
54
+ attrs["aws.error.code"] = code
55
+ fields["attributes"] = attrs
56
+ return fields
57
+
58
+
59
+ def usage_from_converse(value: Any) -> dict[str, int] | None:
60
+ if not isinstance(value, dict):
61
+ return None
62
+ usage = clean(
63
+ {
64
+ "input_tokens": value.get("inputTokens"),
65
+ "output_tokens": value.get("outputTokens"),
66
+ "total_tokens": value.get("totalTokens"),
67
+ "cache_read_input_tokens": value.get("cacheReadInputTokens"),
68
+ "cache_creation_input_tokens": value.get("cacheWriteInputTokens"),
69
+ }
70
+ )
71
+ return usage or None
72
+
73
+
74
+ def metadata_attr_value(value: Any) -> Any:
75
+ if value is None or isinstance(value, str):
76
+ return value
77
+ return json.dumps(value, default=repr, ensure_ascii=False)
78
+
79
+
80
+ def merge_fields(*parts: dict[str, Any]) -> dict[str, Any]:
81
+ merged: dict[str, Any] = {}
82
+ for part in parts:
83
+ metadata = part.get("metadata")
84
+ attributes = part.get("attributes")
85
+ usage = part.get("usage")
86
+ for key, value in part.items():
87
+ if key not in {"metadata", "attributes", "usage"} and value is not None:
88
+ merged[key] = value
89
+ if isinstance(metadata, dict):
90
+ merged["metadata"] = {
91
+ **merged.get("metadata", {}),
92
+ **{
93
+ key: normalized
94
+ for key, value in metadata.items()
95
+ if (normalized := metadata_attr_value(value)) is not None
96
+ },
97
+ }
98
+ if isinstance(attributes, dict):
99
+ merged["attributes"] = {**merged.get("attributes", {}), **attributes}
100
+ if isinstance(usage, dict):
101
+ merged["usage"] = {**merged.get("usage", {}), **usage}
102
+ return merged
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import threading
5
+ import time
6
+ from collections.abc import Callable
7
+ from dataclasses import dataclass
8
+ from typing import Any, TypeVar, cast
9
+
10
+ import telemetry_dev
11
+ from botocore.client import BaseClient
12
+ from botocore.exceptions import ClientError
13
+ from botocore.response import StreamingBody
14
+
15
+ from ._fields import error_fields, merge_fields, metadata_fields
16
+ from ._invoke_model import parse_body
17
+ from ._registry import OperationSpec, lookup_operation
18
+ from ._streams import InstrumentedEventStream
19
+
20
+ _T = TypeVar("_T", bound=BaseClient)
21
+ _SUPPORTED_SERVICES = frozenset({"bedrock-runtime", "bedrock-agent-runtime"})
22
+ _WRAPPED_ATTR = "_telemetry_dev_bedrock_wrapped"
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class _Options:
27
+ capture_agent_trace: bool = False
28
+
29
+
30
+ _original_api_call: Callable[..., Any] | None = None
31
+ _patched_api_call: Callable[..., Any] | None = None
32
+ _LOCK = threading.Lock()
33
+
34
+
35
+ def wrap_bedrock(client: _T, *, capture_agent_trace: bool = False) -> _T:
36
+ with _LOCK:
37
+ if getattr(client, _WRAPPED_ATTR, False):
38
+ return client
39
+ options = _Options(capture_agent_trace=capture_agent_trace)
40
+ mapping = getattr(client.meta, "method_to_api_mapping", {})
41
+ if not isinstance(mapping, dict):
42
+ return client
43
+ for method_name, operation_name in mapping.items():
44
+ if not isinstance(method_name, str) or not isinstance(operation_name, str):
45
+ continue
46
+ service_name = client.meta.service_model.service_name
47
+ if lookup_operation(service_name, operation_name) is None or not hasattr(
48
+ client, method_name
49
+ ):
50
+ continue
51
+ original = getattr(client, method_name)
52
+ setattr(
53
+ client, method_name, _make_method_wrapper(client, operation_name, original, options)
54
+ )
55
+ setattr(client, _WRAPPED_ATTR, True)
56
+ return client
57
+
58
+
59
+ def instrument_bedrock(*, capture_agent_trace: bool = False) -> None:
60
+ options = _Options(capture_agent_trace=capture_agent_trace)
61
+ with _LOCK:
62
+ global _original_api_call, _patched_api_call
63
+ if _original_api_call is not None:
64
+ return
65
+ original = BaseClient._make_api_call
66
+ _original_api_call = original
67
+
68
+ def patched(self: BaseClient, operation_name: str, api_params: dict[str, Any]) -> Any:
69
+ if getattr(self, _WRAPPED_ATTR, False):
70
+ return original(self, operation_name, api_params)
71
+ return _call_with_span(
72
+ lambda op_name, params: original(self, op_name, params),
73
+ self,
74
+ operation_name,
75
+ api_params,
76
+ options,
77
+ )
78
+
79
+ _patched_api_call = patched
80
+ BaseClient._make_api_call = patched # type: ignore[method-assign]
81
+
82
+
83
+ def uninstrument_bedrock() -> None:
84
+ with _LOCK:
85
+ global _original_api_call, _patched_api_call
86
+ if _original_api_call is None or _patched_api_call is None:
87
+ return
88
+ if BaseClient._make_api_call is not _patched_api_call:
89
+ return
90
+ BaseClient._make_api_call = _original_api_call # type: ignore[method-assign]
91
+ _original_api_call = None
92
+ _patched_api_call = None
93
+
94
+
95
+ def _make_method_wrapper(
96
+ client: BaseClient,
97
+ operation_name: str,
98
+ original: Callable[..., Any],
99
+ options: _Options,
100
+ ) -> Callable[..., Any]:
101
+ def wrapped(*args: Any, **kwargs: Any) -> Any:
102
+ if args:
103
+ return original(*args, **kwargs)
104
+ return _call_with_span(
105
+ lambda _op, params: original(**params), client, operation_name, kwargs, options
106
+ )
107
+
108
+ return wrapped
109
+
110
+
111
+ def _call_with_span(
112
+ original: Callable[[str, dict[str, Any]], Any],
113
+ client: BaseClient,
114
+ operation_name: str,
115
+ api_params: dict[str, Any],
116
+ options: _Options,
117
+ ) -> Any:
118
+ service_name = client.meta.service_model.service_name
119
+ if service_name not in _SUPPORTED_SERVICES:
120
+ return original(operation_name, api_params)
121
+ spec = lookup_operation(service_name, operation_name)
122
+ if spec is None:
123
+ return original(operation_name, api_params)
124
+
125
+ started_at = time.perf_counter()
126
+ name, start_fields = _safe_start_fields(spec, api_params)
127
+ handle = telemetry_dev.start_span(name, **start_fields)
128
+ end = _end_once(handle)
129
+
130
+ try:
131
+ response = original(operation_name, api_params)
132
+ except ClientError as exc:
133
+ end(**error_fields(exc))
134
+ raise
135
+ except BaseException as exc:
136
+ end(error=exc)
137
+ raise
138
+
139
+ if not isinstance(response, dict):
140
+ end()
141
+ return response
142
+
143
+ if operation_name == "InvokeModel" and isinstance(response.get("body"), StreamingBody):
144
+ return _handle_streaming_body(spec, api_params, response, end)
145
+
146
+ stream_key = spec.stream_key
147
+ if stream_key is not None and response.get(stream_key) is not None:
148
+ state_factory = spec.stream_state_factory
149
+ if state_factory is None:
150
+ end(**metadata_fields(response))
151
+ return response
152
+ state = state_factory(options.capture_agent_trace)
153
+ try:
154
+ base_fields = merge_fields(
155
+ metadata_fields(response), spec.response_fields(api_params, response)
156
+ )
157
+ except Exception:
158
+ base_fields = metadata_fields(response)
159
+
160
+ def finish(fields: dict[str, Any]) -> None:
161
+ if fields.keys() == {"time_to_first_chunk_ms"}:
162
+ try:
163
+ handle.update(**fields)
164
+ except Exception:
165
+ pass
166
+ return
167
+ end(**merge_fields(base_fields, fields))
168
+
169
+ return {
170
+ **response,
171
+ stream_key: InstrumentedEventStream(response[stream_key], state, finish, started_at),
172
+ }
173
+
174
+ end(**merge_fields(metadata_fields(response), spec.response_fields(api_params, response)))
175
+ return response
176
+
177
+
178
+ def _handle_streaming_body(
179
+ spec: OperationSpec,
180
+ params: dict[str, Any],
181
+ response: dict[str, Any],
182
+ end: Callable[..., None],
183
+ ) -> dict[str, Any]:
184
+ body = cast(StreamingBody, response["body"])
185
+ try:
186
+ raw = body.read()
187
+ except Exception:
188
+ end(**metadata_fields(response))
189
+ return response
190
+ parsed = parse_body(raw, response.get("contentType"))
191
+ response_for_fields = {**response, "_telemetry_dev_parsed_body": parsed}
192
+ try:
193
+ fields = merge_fields(
194
+ metadata_fields(response), spec.response_fields(params, response_for_fields)
195
+ )
196
+ except Exception:
197
+ fields = metadata_fields(response)
198
+ end(**fields)
199
+ return {**response, "body": StreamingBody(io.BytesIO(raw), len(raw))}
200
+
201
+
202
+ def _safe_start_fields(spec: OperationSpec, params: dict[str, Any]) -> tuple[str, dict[str, Any]]:
203
+ try:
204
+ name = spec.span_name(params)
205
+ except Exception:
206
+ name = "bedrock"
207
+ try:
208
+ fields = spec.request_fields(params)
209
+ except Exception:
210
+ fields = {}
211
+ try:
212
+ fields = {"type": spec.span_type(params), **fields}
213
+ except Exception:
214
+ fields = {"type": "span", **fields}
215
+ return name, fields
216
+
217
+
218
+ def _end_once(handle: telemetry_dev.SpanHandle) -> Callable[..., None]:
219
+ ended = False
220
+
221
+ def end(**fields: Any) -> None:
222
+ nonlocal ended
223
+ if ended:
224
+ return
225
+ ended = True
226
+ try:
227
+ handle.end(**fields)
228
+ except Exception:
229
+ pass
230
+
231
+ return end
@@ -0,0 +1,241 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from ._fields import clean
7
+
8
+
9
+ def parse_body(body: Any, content_type: Any = "application/json") -> Any:
10
+ if isinstance(content_type, str) and "json" not in content_type:
11
+ return None
12
+ if isinstance(body, bytes | bytearray):
13
+ raw = bytes(body).decode("utf-8")
14
+ elif isinstance(body, str):
15
+ raw = body
16
+ elif all(hasattr(body, name) for name in ("tell", "read", "seek")):
17
+ seekable = getattr(body, "seekable", None)
18
+ if callable(seekable):
19
+ try:
20
+ if not seekable():
21
+ return None
22
+ except Exception:
23
+ return None
24
+ try:
25
+ position = body.tell()
26
+ body.seek(position)
27
+ value = body.read()
28
+ body.seek(position)
29
+ except Exception:
30
+ return None
31
+ if isinstance(value, bytes | bytearray):
32
+ raw = bytes(value).decode("utf-8")
33
+ elif isinstance(value, str):
34
+ raw = value
35
+ else:
36
+ return None
37
+ else:
38
+ return None
39
+ try:
40
+ return json.loads(raw)
41
+ except Exception:
42
+ return None
43
+
44
+
45
+ def is_embedding_model(model_id: Any) -> bool:
46
+ model = model_id.lower() if isinstance(model_id, str) else ""
47
+ return "titan-embed" in model or "cohere.embed" in model or "-embedding" in model
48
+
49
+
50
+ def request_fields(params: dict[str, Any]) -> dict[str, Any]:
51
+ model = params.get("modelId") if isinstance(params.get("modelId"), str) else None
52
+ body = parse_body(params.get("body"), params.get("contentType"))
53
+ fields = {"provider": "amazon-bedrock", "model": model, "input": body}
54
+ if is_embedding_model(model):
55
+ fields["output_type"] = "embedding"
56
+ fields.update(sampling_fields(model, body))
57
+ return clean(fields)
58
+
59
+
60
+ def sampling_fields(model: str | None, body: Any) -> dict[str, Any]:
61
+ if not isinstance(body, dict):
62
+ return {}
63
+ lower = model.lower() if isinstance(model, str) else ""
64
+ if "amazon.titan" in lower:
65
+ cfg = (
66
+ body.get("textGenerationConfig")
67
+ if isinstance(body.get("textGenerationConfig"), dict)
68
+ else {}
69
+ )
70
+ return clean(
71
+ {
72
+ "temperature": cfg.get("temperature"),
73
+ "top_p": cfg.get("topP"),
74
+ "max_tokens": cfg.get("maxTokenCount"),
75
+ "stop_sequences": cfg.get("stopSequences"),
76
+ }
77
+ )
78
+ if "amazon.nova" in lower:
79
+ cfg = body.get("inferenceConfig") if isinstance(body.get("inferenceConfig"), dict) else {}
80
+ return clean(
81
+ {
82
+ "temperature": cfg.get("temperature"),
83
+ "top_p": cfg.get("topP") or cfg.get("top_p"),
84
+ "top_k": cfg.get("topK"),
85
+ "max_tokens": cfg.get("maxTokens") or cfg.get("max_new_tokens"),
86
+ "stop_sequences": cfg.get("stopSequences"),
87
+ }
88
+ )
89
+ if "anthropic.claude" in lower:
90
+ return clean(
91
+ {
92
+ "temperature": body.get("temperature"),
93
+ "top_p": body.get("top_p"),
94
+ "top_k": body.get("top_k"),
95
+ "max_tokens": body.get("max_tokens"),
96
+ "stop_sequences": body.get("stop_sequences"),
97
+ }
98
+ )
99
+ if "meta.llama" in lower:
100
+ return clean(
101
+ {
102
+ "temperature": body.get("temperature"),
103
+ "top_p": body.get("top_p"),
104
+ "max_tokens": body.get("max_gen_len"),
105
+ }
106
+ )
107
+ return clean(
108
+ {
109
+ "temperature": body.get("temperature"),
110
+ "top_p": body.get("top_p") or body.get("topP"),
111
+ "max_tokens": body.get("max_tokens") or body.get("maxTokens"),
112
+ }
113
+ )
114
+
115
+
116
+ def response_fields(
117
+ model: str | None, body: Any, headers: dict[str, Any] | None = None
118
+ ) -> dict[str, Any]:
119
+ if not isinstance(body, dict):
120
+ return header_usage_fields(headers)
121
+ lower = model.lower() if isinstance(model, str) else ""
122
+ if is_embedding_model(model):
123
+ return _with_header_usage(
124
+ {
125
+ "output": body,
126
+ "output_type": "embedding",
127
+ "usage": clean({"input_tokens": body.get("inputTextTokenCount")}) or None,
128
+ },
129
+ headers,
130
+ )
131
+ if "amazon.titan" in lower:
132
+ first = body.get("results", [{}])[0] if isinstance(body.get("results"), list) else {}
133
+ first = first if isinstance(first, dict) else {}
134
+ return clean(
135
+ {
136
+ "output": body,
137
+ "finish_reason": first.get("completionReason"),
138
+ "usage": clean(
139
+ {
140
+ "input_tokens": body.get("inputTextTokenCount"),
141
+ "output_tokens": first.get("tokenCount"),
142
+ }
143
+ )
144
+ or None,
145
+ }
146
+ )
147
+ if "amazon.nova" in lower:
148
+ usage = body.get("usage") if isinstance(body.get("usage"), dict) else {}
149
+ return clean(
150
+ {
151
+ "output": body,
152
+ "finish_reason": body.get("stopReason"),
153
+ "usage": clean(
154
+ {
155
+ "input_tokens": usage.get("inputTokens"),
156
+ "output_tokens": usage.get("outputTokens"),
157
+ "total_tokens": usage.get("totalTokens"),
158
+ }
159
+ )
160
+ or None,
161
+ }
162
+ )
163
+ if "anthropic.claude" in lower:
164
+ usage = body.get("usage") if isinstance(body.get("usage"), dict) else {}
165
+ parsed_usage = clean(
166
+ {
167
+ "input_tokens": usage.get("input_tokens"),
168
+ "output_tokens": usage.get("output_tokens"),
169
+ "cache_read_input_tokens": usage.get("cache_read_input_tokens"),
170
+ "cache_creation_input_tokens": usage.get("cache_creation_input_tokens"),
171
+ }
172
+ )
173
+ return clean(
174
+ {
175
+ "output": body,
176
+ "finish_reason": body.get("stop_reason"),
177
+ "usage": parsed_usage or header_usage_fields(headers).get("usage"),
178
+ }
179
+ )
180
+ if "meta.llama" in lower:
181
+ return clean(
182
+ {
183
+ "output": body,
184
+ "finish_reason": body.get("stop_reason"),
185
+ "usage": clean(
186
+ {
187
+ "input_tokens": body.get("prompt_token_count"),
188
+ "output_tokens": body.get("generation_token_count"),
189
+ }
190
+ )
191
+ or None,
192
+ }
193
+ )
194
+ if "mistral" in lower:
195
+ first = body.get("outputs", [{}])[0] if isinstance(body.get("outputs"), list) else {}
196
+ first = first if isinstance(first, dict) else {}
197
+ return _with_header_usage(
198
+ {"output": body, "finish_reason": first.get("stop_reason")}, headers
199
+ )
200
+ if "cohere.command-r" in lower:
201
+ return _with_header_usage(
202
+ {"output": body, "finish_reason": body.get("finish_reason")}, headers
203
+ )
204
+ if "cohere.command" in lower:
205
+ first = (
206
+ body.get("generations", [{}])[0] if isinstance(body.get("generations"), list) else {}
207
+ )
208
+ first = first if isinstance(first, dict) else {}
209
+ return _with_header_usage(
210
+ {"output": body, "finish_reason": first.get("finish_reason")}, headers
211
+ )
212
+ return _with_header_usage({"output": body}, headers)
213
+
214
+
215
+ def _with_header_usage(fields: dict[str, Any], headers: dict[str, Any] | None) -> dict[str, Any]:
216
+ cleaned = clean(fields)
217
+ if cleaned.get("usage"):
218
+ return cleaned
219
+ return clean({**cleaned, **header_usage_fields(headers)})
220
+
221
+
222
+ def header_usage_fields(headers: dict[str, Any] | None) -> dict[str, Any]:
223
+ if not headers:
224
+ return {}
225
+ input_tokens = _int_header(headers, "x-amzn-bedrock-input-token-count")
226
+ output_tokens = _int_header(headers, "x-amzn-bedrock-output-token-count")
227
+ return clean(
228
+ {
229
+ "usage": clean({"input_tokens": input_tokens, "output_tokens": output_tokens}) or None,
230
+ }
231
+ )
232
+
233
+
234
+ def _int_header(headers: dict[str, Any], key: str) -> int | None:
235
+ value = headers.get(key)
236
+ if value is None:
237
+ return None
238
+ try:
239
+ return int(value)
240
+ except (TypeError, ValueError):
241
+ return None