Skip to content

Commit 8bae410

Browse files
feat: Emit OTEL attributes for AgentCore Evaluation support (#368)
* feat: Emit OTEL attributes for AgentCore Evaluation support Add _emit_invocation_otel_attributes() to BedrockAgentCoreApp that automatically emits agentcore.invocation.user_prompt and agentcore.invocation.agent_response as OTEL span attributes on the root POST /invocations span. These attributes provide a canonical, framework-agnostic source of the user's prompt and the agent's response for AgentCore Evaluation. They enable evaluation of workflow agents that use custom state schemas (e.g. TypedDict with user_input/final_response fields) where the default MessagesState-based extraction in the evaluation mapper would fail silently with null scores. The method: - Extracts user prompt from the payload dict (tries common keys like prompt, input, query, message, falls back to full JSON) - Extracts agent response from the entrypoint return value - Skips silently for streaming responses or when OTEL is not installed - Attributes are capped at 16KB to stay within OTEL limits * Do not override user_prompt/agent_response if already set on span Check span.attributes before setting agentcore.invocation.user_prompt and agentcore.invocation.agent_response so that user-provided values are not overwritten by auto-extraction. * Address PR feedback: prompt_key/response_key, Response early-return fix 1. Remove broken span.attributes guard — active Span doesn't expose .attributes (only ReadableSpan does), so the check was a no-op in prod. 2. Add prompt_key and response_key params to @app.entrypoint() so users can control which payload/result keys are used for OTEL attributes. Default (None) preserves the existing heuristic behavior. 3. Fix early return on Response objects — previously skipped user_prompt emission too. Now only skips agent_response for streaming responses. --------- Co-authored-by: Lior Shoval <lshoval@amazon.com>
1 parent 2c4911d commit 8bae410

2 files changed

Lines changed: 386 additions & 4 deletions

File tree

src/bedrock_agentcore/runtime/app.py

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,8 @@ def __init__(
120120
self.handlers: Dict[str, Callable] = {}
121121
self._ping_handler: Optional[Callable] = None
122122
self._websocket_handler: Optional[Callable] = None
123+
self._prompt_key: Optional[str] = None
124+
self._response_key: Optional[str] = None
123125
self._active_tasks: Dict[int, Dict[str, Any]] = {}
124126
self._task_counter_lock: threading.Lock = threading.Lock()
125127
self._forced_ping_status: Optional[PingStatus] = None
@@ -144,18 +146,44 @@ def __init__(
144146
self.logger.addHandler(handler)
145147
self.logger.setLevel(logging.DEBUG if self.debug else logging.INFO)
146148

147-
def entrypoint(self, func: Callable) -> Callable:
149+
def entrypoint(
150+
self, func: Callable = None, *, prompt_key: Optional[str] = None, response_key: Optional[str] = None
151+
) -> Callable:
148152
"""Decorator to register a function as the main entrypoint.
149153
150154
Args:
151155
func: The function to register as entrypoint
156+
prompt_key: Optional key to extract user prompt from the payload dict.
157+
If not specified, tries common keys in order (prompt, input, query,
158+
message, question, user_input) then falls back to JSON serialization.
159+
response_key: Optional key to extract agent response from the result dict.
160+
If not specified, the full result is used.
152161
153162
Returns:
154163
The decorated function with added serve method
164+
165+
Examples:
166+
@app.entrypoint
167+
def handler(payload):
168+
...
169+
170+
@app.entrypoint(prompt_key="user_input")
171+
def handler(payload):
172+
...
155173
"""
156-
self.handlers["main"] = func
157-
func.run = lambda port=8080, host=None: self.run(port, host)
158-
return func
174+
175+
def decorator(f: Callable) -> Callable:
176+
self.handlers["main"] = f
177+
self._prompt_key = prompt_key
178+
self._response_key = response_key
179+
f.run = lambda port=8080, host=None: self.run(port, host)
180+
return f
181+
182+
if func is not None:
183+
# Called as @app.entrypoint without arguments
184+
return decorator(func)
185+
# Called as @app.entrypoint(...) with arguments
186+
return decorator
159187

160188
def ping(self, func: Callable) -> Callable:
161189
"""Decorator to register a custom ping status handler.
@@ -384,6 +412,78 @@ def _takes_context(self, handler: Callable) -> bool:
384412
except Exception:
385413
return False
386414

415+
def _emit_invocation_otel_attributes(self, payload: Any, result: Any) -> None:
416+
"""Emit OTEL span attributes with the invocation input and output.
417+
418+
These attributes provide a canonical, framework-agnostic source of the
419+
user's prompt and the agent's response for AgentCore Evaluation. They
420+
enable evaluation of agents that use custom state schemas (e.g. workflow
421+
agents with TypedDict states) where the default MessagesState-based
422+
extraction in the evaluation mapper would fail.
423+
424+
The attributes are set on the current active span (typically the root
425+
POST /invocations span created by ADOT auto-instrumentation).
426+
427+
When prompt_key or response_key is configured via @app.entrypoint(),
428+
those specific keys are used to extract from dict payloads/results.
429+
Otherwise, a heuristic tries common keys in priority order.
430+
"""
431+
try:
432+
from opentelemetry import trace as otel_trace
433+
434+
span = otel_trace.get_current_span()
435+
if not span or not span.is_recording():
436+
return
437+
438+
# Extract user prompt from payload
439+
user_prompt = None
440+
if isinstance(payload, dict):
441+
if self._prompt_key is not None:
442+
value = payload.get(self._prompt_key)
443+
if isinstance(value, str):
444+
user_prompt = value
445+
else:
446+
for key in ("prompt", "input", "query", "message", "question", "user_input"):
447+
if key in payload and isinstance(payload[key], str):
448+
user_prompt = payload[key]
449+
break
450+
if user_prompt is None:
451+
user_prompt = json.dumps(payload, ensure_ascii=False, default=str)
452+
elif isinstance(payload, str):
453+
user_prompt = payload
454+
else:
455+
user_prompt = str(payload)
456+
457+
# Extract agent response from result
458+
agent_response = None
459+
if isinstance(result, str):
460+
agent_response = result
461+
elif isinstance(result, Response):
462+
pass # Skip for streaming/custom responses — cannot capture full output
463+
elif isinstance(result, dict) and self._response_key is not None:
464+
value = result.get(self._response_key)
465+
if isinstance(value, str):
466+
agent_response = value
467+
elif value is not None:
468+
try:
469+
agent_response = json.dumps(value, ensure_ascii=False, default=str)
470+
except Exception:
471+
agent_response = str(value)
472+
elif result is not None:
473+
try:
474+
agent_response = json.dumps(result, ensure_ascii=False, default=str)
475+
except Exception:
476+
agent_response = str(result)
477+
478+
if user_prompt:
479+
span.set_attribute("agentcore.invocation.user_prompt", user_prompt[:16384])
480+
if agent_response:
481+
span.set_attribute("agentcore.invocation.agent_response", agent_response[:16384])
482+
except ImportError:
483+
pass # OpenTelemetry not installed — silently skip
484+
except Exception:
485+
self.logger.debug("Failed to emit invocation OTEL attributes", exc_info=True)
486+
387487
async def _handle_invocation(self, request):
388488
request_context = self._build_request_context(request)
389489

@@ -411,6 +511,8 @@ async def _handle_invocation(self, request):
411511
self.logger.debug("Invoking handler: %s", handler_name)
412512
result = await self._invoke_handler(handler, request_context, takes_context, payload)
413513

514+
self._emit_invocation_otel_attributes(payload, result)
515+
414516
duration = time.time() - start_time
415517
if inspect.isgenerator(result):
416518
self.logger.info("Returning streaming response (generator) (%.3fs)", duration)

0 commit comments

Comments
 (0)