Skip to content

Commit 1206daf

Browse files
nirgaclaude
andauthored
fix(anthropic): support with_raw_response wrapper for span generation (#3250)
Co-authored-by: Claude <[email protected]>
1 parent f16da74 commit 1206daf

File tree

9 files changed

+770
-26
lines changed

9 files changed

+770
-26
lines changed

packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/__init__.py

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,32 @@
8181
"method": "stream",
8282
"span_name": "anthropic.chat",
8383
},
84+
# Beta API methods (regular Anthropic SDK)
85+
{
86+
"package": "anthropic.resources.beta.messages.messages",
87+
"object": "Messages",
88+
"method": "create",
89+
"span_name": "anthropic.chat",
90+
},
91+
{
92+
"package": "anthropic.resources.beta.messages.messages",
93+
"object": "Messages",
94+
"method": "stream",
95+
"span_name": "anthropic.chat",
96+
},
97+
# Beta API methods (Bedrock SDK)
98+
{
99+
"package": "anthropic.lib.bedrock._beta_messages",
100+
"object": "Messages",
101+
"method": "create",
102+
"span_name": "anthropic.chat",
103+
},
104+
{
105+
"package": "anthropic.lib.bedrock._beta_messages",
106+
"object": "Messages",
107+
"method": "stream",
108+
"span_name": "anthropic.chat",
109+
},
84110
]
85111

86112
WRAPPED_AMETHODS = [
@@ -96,6 +122,32 @@
96122
"method": "create",
97123
"span_name": "anthropic.chat",
98124
},
125+
# Beta API async methods (regular Anthropic SDK)
126+
{
127+
"package": "anthropic.resources.beta.messages.messages",
128+
"object": "AsyncMessages",
129+
"method": "create",
130+
"span_name": "anthropic.chat",
131+
},
132+
{
133+
"package": "anthropic.resources.beta.messages.messages",
134+
"object": "AsyncMessages",
135+
"method": "stream",
136+
"span_name": "anthropic.chat",
137+
},
138+
# Beta API async methods (Bedrock SDK)
139+
{
140+
"package": "anthropic.lib.bedrock._beta_messages",
141+
"object": "AsyncMessages",
142+
"method": "create",
143+
"span_name": "anthropic.chat",
144+
},
145+
{
146+
"package": "anthropic.lib.bedrock._beta_messages",
147+
"object": "AsyncMessages",
148+
"method": "stream",
149+
"span_name": "anthropic.chat",
150+
},
99151
]
100152

101153

@@ -130,8 +182,8 @@ async def _aset_token_usage(
130182
token_histogram: Histogram = None,
131183
choice_counter: Counter = None,
132184
):
133-
if not isinstance(response, dict):
134-
response = response.__dict__
185+
from opentelemetry.instrumentation.anthropic.utils import _aextract_response_data
186+
response = await _aextract_response_data(response)
135187

136188
if usage := response.get("usage"):
137189
prompt_tokens = usage.input_tokens
@@ -223,8 +275,8 @@ def _set_token_usage(
223275
token_histogram: Histogram = None,
224276
choice_counter: Counter = None,
225277
):
226-
if not isinstance(response, dict):
227-
response = response.__dict__
278+
from opentelemetry.instrumentation.anthropic.utils import _extract_response_data
279+
response = _extract_response_data(response)
228280

229281
if usage := response.get("usage"):
230282
prompt_tokens = usage.input_tokens
@@ -384,6 +436,17 @@ async def _ahandle_input(span: Span, event_logger: Optional[EventLogger], kwargs
384436
await aset_input_attributes(span, kwargs)
385437

386438

439+
@dont_throw
440+
async def _ahandle_response(span: Span, event_logger: Optional[EventLogger], response):
441+
if should_emit_events():
442+
emit_response_events(event_logger, response)
443+
else:
444+
if not span.is_recording():
445+
return
446+
from opentelemetry.instrumentation.anthropic.span_utils import aset_response_attributes
447+
await aset_response_attributes(span, response)
448+
449+
387450
@dont_throw
388451
def _handle_response(span: Span, event_logger: Optional[EventLogger], response):
389452
if should_emit_events():
@@ -606,7 +669,8 @@ async def _awrap(
606669
kwargs,
607670
)
608671
elif response:
609-
metric_attributes = shared_metrics_attributes(response)
672+
from opentelemetry.instrumentation.anthropic.utils import ashared_metrics_attributes
673+
metric_attributes = await ashared_metrics_attributes(response)
610674

611675
if duration_histogram:
612676
duration = time.time() - start_time
@@ -615,7 +679,7 @@ async def _awrap(
615679
attributes=metric_attributes,
616680
)
617681

618-
_handle_response(span, event_logger, response)
682+
await _ahandle_response(span, event_logger, response)
619683

620684
if span.is_recording():
621685
await _aset_token_usage(
@@ -710,7 +774,9 @@ def _instrument(self, **kwargs):
710774
wrapped_method,
711775
),
712776
)
713-
except ModuleNotFoundError:
777+
logger.debug(f"Successfully wrapped {wrap_package}.{wrap_object}.{wrap_method}")
778+
except Exception as e:
779+
logger.debug(f"Failed to wrap {wrap_package}.{wrap_object}.{wrap_method}: {e}")
714780
pass # that's ok, we don't want to fail if some methods do not exist
715781

716782
for wrapped_method in WRAPPED_AMETHODS:
@@ -731,7 +797,7 @@ def _instrument(self, **kwargs):
731797
wrapped_method,
732798
),
733799
)
734-
except ModuleNotFoundError:
800+
except Exception:
735801
pass # that's ok, we don't want to fail if some methods do not exist
736802

737803
def _uninstrument(self, **kwargs):

packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/span_utils.py

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
dont_throw,
99
model_as_dict,
1010
should_send_prompts,
11+
_extract_response_data,
1112
)
1213
from opentelemetry.semconv._incubating.attributes.gen_ai_attributes import (
1314
GEN_AI_RESPONSE_ID,
@@ -165,11 +166,81 @@ async def aset_input_attributes(span, kwargs):
165166
)
166167

167168

169+
async def _aset_span_completions(span, response):
170+
if not should_send_prompts():
171+
return
172+
from opentelemetry.instrumentation.anthropic import set_span_attribute
173+
from opentelemetry.instrumentation.anthropic.utils import _aextract_response_data
174+
175+
response = await _aextract_response_data(response)
176+
index = 0
177+
prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}"
178+
set_span_attribute(span, f"{prefix}.finish_reason", response.get("stop_reason"))
179+
if response.get("role"):
180+
set_span_attribute(span, f"{prefix}.role", response.get("role"))
181+
182+
if response.get("completion"):
183+
set_span_attribute(span, f"{prefix}.content", response.get("completion"))
184+
elif response.get("content"):
185+
tool_call_index = 0
186+
text = ""
187+
for content in response.get("content"):
188+
content_block_type = content.type
189+
# usually, Antrhopic responds with just one text block,
190+
# but the API allows for multiple text blocks, so concatenate them
191+
if content_block_type == "text" and hasattr(content, "text"):
192+
text += content.text
193+
elif content_block_type == "thinking":
194+
content = dict(content)
195+
# override the role to thinking
196+
set_span_attribute(
197+
span,
198+
f"{prefix}.role",
199+
"thinking",
200+
)
201+
set_span_attribute(
202+
span,
203+
f"{prefix}.content",
204+
content.get("thinking"),
205+
)
206+
# increment the index for subsequent content blocks
207+
index += 1
208+
prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}"
209+
# set the role to the original role on the next completions
210+
set_span_attribute(
211+
span,
212+
f"{prefix}.role",
213+
response.get("role"),
214+
)
215+
elif content_block_type == "tool_use":
216+
content = dict(content)
217+
set_span_attribute(
218+
span,
219+
f"{prefix}.tool_calls.{tool_call_index}.id",
220+
content.get("id"),
221+
)
222+
set_span_attribute(
223+
span,
224+
f"{prefix}.tool_calls.{tool_call_index}.name",
225+
content.get("name"),
226+
)
227+
tool_arguments = content.get("input")
228+
if tool_arguments is not None:
229+
set_span_attribute(
230+
span,
231+
f"{prefix}.tool_calls.{tool_call_index}.arguments",
232+
json.dumps(tool_arguments),
233+
)
234+
tool_call_index += 1
235+
set_span_attribute(span, f"{prefix}.content", text)
236+
237+
168238
def _set_span_completions(span, response):
169239
if not should_send_prompts():
170240
return
171241
from opentelemetry.instrumentation.anthropic import set_span_attribute
172242

243+
response = _extract_response_data(response)
173244
index = 0
174245
prefix = f"{SpanAttributes.LLM_COMPLETIONS}.{index}"
175246
set_span_attribute(span, f"{prefix}.finish_reason", response.get("stop_reason"))
@@ -232,12 +303,36 @@ def _set_span_completions(span, response):
232303
set_span_attribute(span, f"{prefix}.content", text)
233304

234305

306+
@dont_throw
307+
async def aset_response_attributes(span, response):
308+
from opentelemetry.instrumentation.anthropic import set_span_attribute
309+
from opentelemetry.instrumentation.anthropic.utils import _aextract_response_data
310+
311+
response = await _aextract_response_data(response)
312+
set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response.get("model"))
313+
set_span_attribute(span, GEN_AI_RESPONSE_ID, response.get("id"))
314+
315+
if response.get("usage"):
316+
prompt_tokens = response.get("usage").input_tokens
317+
completion_tokens = response.get("usage").output_tokens
318+
set_span_attribute(span, SpanAttributes.LLM_USAGE_PROMPT_TOKENS, prompt_tokens)
319+
set_span_attribute(
320+
span, SpanAttributes.LLM_USAGE_COMPLETION_TOKENS, completion_tokens
321+
)
322+
set_span_attribute(
323+
span,
324+
SpanAttributes.LLM_USAGE_TOTAL_TOKENS,
325+
prompt_tokens + completion_tokens,
326+
)
327+
328+
await _aset_span_completions(span, response)
329+
330+
235331
@dont_throw
236332
def set_response_attributes(span, response):
237333
from opentelemetry.instrumentation.anthropic import set_span_attribute
238334

239-
if not isinstance(response, dict):
240-
response = response.__dict__
335+
response = _extract_response_data(response)
241336
set_span_attribute(span, SpanAttributes.LLM_RESPONSE_MODEL, response.get("model"))
242337
set_span_attribute(span, GEN_AI_RESPONSE_ID, response.get("id"))
243338

packages/opentelemetry-instrumentation-anthropic/opentelemetry/instrumentation/anthropic/utils.py

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,95 @@ def _handle_exception(e, func, logger):
6161
return async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper
6262

6363

64+
async def _aextract_response_data(response):
65+
"""Async version of _extract_response_data that can await coroutines."""
66+
import inspect
67+
68+
# If we get a coroutine, await it
69+
if inspect.iscoroutine(response):
70+
try:
71+
response = await response
72+
except Exception as e:
73+
import logging
74+
logger = logging.getLogger(__name__)
75+
logger.debug(f"Failed to await coroutine response: {e}")
76+
return {}
77+
78+
if isinstance(response, dict):
79+
return response
80+
81+
# Handle with_raw_response wrapped responses
82+
if hasattr(response, 'parse') and callable(response.parse):
83+
try:
84+
# For with_raw_response, parse() gives us the actual response object
85+
parsed_response = response.parse()
86+
if not isinstance(parsed_response, dict):
87+
parsed_response = parsed_response.__dict__
88+
return parsed_response
89+
except Exception as e:
90+
import logging
91+
logger = logging.getLogger(__name__)
92+
logger.debug(f"Failed to parse response: {e}, response type: {type(response)}")
93+
94+
# Fallback to __dict__ for regular response objects
95+
if hasattr(response, '__dict__'):
96+
response_dict = response.__dict__
97+
return response_dict
98+
99+
return {}
100+
101+
102+
def _extract_response_data(response):
103+
"""Extract the actual response data from both regular and with_raw_response wrapped responses."""
104+
import inspect
105+
106+
# If we get a coroutine, we cannot process it in sync context
107+
if inspect.iscoroutine(response):
108+
import logging
109+
logger = logging.getLogger(__name__)
110+
logger.warning(f"_extract_response_data received coroutine {response} - response processing skipped")
111+
return {}
112+
113+
if isinstance(response, dict):
114+
return response
115+
116+
# Handle with_raw_response wrapped responses
117+
if hasattr(response, 'parse') and callable(response.parse):
118+
try:
119+
# For with_raw_response, parse() gives us the actual response object
120+
parsed_response = response.parse()
121+
if not isinstance(parsed_response, dict):
122+
parsed_response = parsed_response.__dict__
123+
return parsed_response
124+
except Exception as e:
125+
import logging
126+
logger = logging.getLogger(__name__)
127+
logger.debug(f"Failed to parse response: {e}, response type: {type(response)}")
128+
129+
# Fallback to __dict__ for regular response objects
130+
if hasattr(response, '__dict__'):
131+
response_dict = response.__dict__
132+
return response_dict
133+
134+
return {}
135+
136+
137+
@dont_throw
138+
async def ashared_metrics_attributes(response):
139+
response = await _aextract_response_data(response)
140+
141+
common_attributes = Config.get_common_metrics_attributes()
142+
143+
return {
144+
**common_attributes,
145+
GEN_AI_SYSTEM: GEN_AI_SYSTEM_ANTHROPIC,
146+
SpanAttributes.LLM_RESPONSE_MODEL: response.get("model"),
147+
}
148+
149+
64150
@dont_throw
65151
def shared_metrics_attributes(response):
66-
if not isinstance(response, dict):
67-
response = response.__dict__
152+
response = _extract_response_data(response)
68153

69154
common_attributes = Config.get_common_metrics_attributes()
70155

0 commit comments

Comments
 (0)