Evals, observability
and governance
Section 7. Your agents are running on AWS. Now you'll answer the three questions every production team gets asked: how well is it doing? (evaluation), what is it doing? (observability) and what is it allowed to do? (governance). You'll score a deployed agent with an LLM judge, trace every step it takes, and put hard limits on what it can say and do.
Deployed is not the same as trusted
You can now design agents, give them tools and memory, organise them into teams, connect them over MCP and A2A, and deploy them on AWS. That's how you build one. Running it with confidence takes more.
For an agent to succeed in a real business it must also be observable, reliable, secure and governed responsibly. Without these, even a very capable agent is hard to trust, hard to monitor and hard to scale.
As agents take on more autonomy, touch sensitive data and make decisions inside business workflows, new questions appear. Is the agent actually good at its job, and is it staying good? When it fails, which step went wrong? Is it hallucinating? Which tools does it call, how often, and at what cost? And what stops it doing something it shouldn't, like issuing a refund nobody approved? All of that has to work without innovation coming at the cost of compliance, safety or accountability.
✦Think of it like this
Your company has hired a new delivery driver. Before you hand over the keys you give a driving test, and you keep doing spot checks (evaluation). The van has a dashcam and a trip recorder showing every turn, stop and litre of fuel (observability). And there are rules nobody can talk their way past: a speed limiter in the engine, a list of roads the van may use, plus a manager who signs off the routes and answers for accidents (governance). Agents need all three, for the same reasons.
What you'll learn here
- Agent evaluation: why it matters, what to check, how it depends on traces, built-in and custom evaluators, and AgentCore Evaluations, with a lab that scores a deployed travel agent
- Observability: what to capture, the OpenTelemetry standard, and five ways to view the same traces: the console, Jaeger, Langfuse, LangSmith and AgentCore Observability in CloudWatch, plus a collector that sends traces to several places at once
- AI governance: Bedrock Guardrails for what the agent says, AgentCore Policy with Cedar for what it does, and the human layer of agent cards, audit trails and incident response
Before you start
- 1
A working Strands setup from earlier sections
Plus basic Python functions and classes. Familiarity with REST APIs and JSON helps but isn't required.
- 2
An AWS account with permissions to create resources
The labs deploy an agent to AgentCore Runtime, create evaluators, guardrails, a gateway, a policy engine, a Lambda function and IAM roles, and call Claude on Amazon Bedrock. Set
AWS_REGION=us-east-1. - 3
Turn on CloudWatch Transaction Search, once
CloudWatch console → Application Signals → Transaction Search → Enable. AgentCore Evaluations and the CloudWatch GenAI Observability dashboard both read your agent's traces from it. You do this once per account and Region.
- 4
Optional accounts and Docker
Free accounts with Langfuse and LangSmith for those two notebooks, and a Datadog account (a trial works) for the collector fan-out. Docker for Jaeger and the OpenTelemetry Collector. Every other lab works without them.
- 5
An IDE and Jupyter
VS Code, Kiro, Claude Code or Cursor; almost every lab in this section is a notebook.
| Folder | What's inside | Answers | On this page |
|---|---|---|---|
agent-evaluation/ | end_to_end_eval.ipynb deploys a travel assistant (agent_app.py) to AgentCore Runtime, creates a custom LLM judge (travel_quality_metric.json), invokes it and scores it | How well is it doing? | 7.5 |
agent-observability/ | Five notebooks sending the same traces to the console, Jaeger, Langfuse, LangSmith and CloudWatch (agentcore_agent.py), plus otel-collector/ for fan-out | What is it doing? | 7.7–7.11 |
ai-governance/ | 01_bedrock_guardrails.ipynb, 02_agentcore_policy_cedar.ipynb, four cedar-policies/ and governance_checklist.md | What is it allowed to do? | 7.13–7.16 |
cd agent-evaluation && pip install -r requirements.txt && jupyter lab end_to_end_eval.ipynb
cd ../agent-observability && pip install -r requirements.txt && jupyter lab
cd ../ai-governance && pip install -r requirements.txt && jupyter labiOne tracing notebook per kernel
OpenTelemetry allows only one global "tracer provider" per Python process. Run each observability notebook in a fresh kernel (Kernel → Restart) or the traces may go to the previous notebook's destination.
!Small costs, but clean up
The evaluation lab deploys a runtime and pays for judge-model calls; guardrails are billed per 1,000 text units; the policy lab creates a gateway, a Lambda function and IAM roles. Every notebook ends with a clean-up cell that deletes what it created. Run it the same day.
Why evaluate agents
Agents now order groceries, book flights, schedule appointments, write clinical notes and answer customers. As interest grows, so does the risk of putting them in front of people before they're ready.
Alongside general assistants, vertical agents have appeared: built for one industry or job, such as clinical documentation in healthcare, legal research assistants and AI customer support, sometimes pitched as replacements for traditional software products. When agents like these aren't tested thoroughly they bring serious problems: inaccurate outputs, hidden biases, poor adaptability to unusual situations and security weaknesses.
Picture a customer support agent that confidently quotes the wrong refund policy, or a healthcare scheduling agent that misreads symptoms and books the wrong specialist. That's more than a performance problem: it confuses users, erodes trust and raises questions about fairness and reliability.
It also has real consequences. In one widely reported case, a company's customer service chatbot gave a customer incorrect policy information, and the customer relied on it when making a purchase. When the customer challenged the outcome, the company was held responsible for what its chatbot had said. If an agent isn't evaluated for accuracy, consistency and policy compliance, the result isn't just a poor experience; it's reputational and legal risk.
AIn plain words: evaluation
means systematically checking whether an agent did the right thing, the right way: not only whether its final answer sounds good, but whether it chose sensible steps to get there. You do it before launch, and you keep doing it afterwards.
Why it matters
Handling the unexpected
The goal is an agent that handles routine tasks and odd edge cases without wrong or unfair outcomes. A loan-assessment agent must apply decisions consistently and fairly to every applicant; a virtual assistant must cope with the unusual question as well as the common one. Finding these issues early stops them reaching real users.
Compliance and trust
In healthcare and finance, AI systems must meet strict requirements for privacy, safety and fairness. Being able to show that an agent was tested against those standards gives regulators, stakeholders and users confidence that it was carefully validated.
It's never finished
An agent that performs well at launch can still get worse as user behaviour, data or business needs change. Regular evaluation catches performance drops, scenarios nobody thought of, and biases that emerge over time, while they're still small.
A use case: book me a trip to San Francisco
Suppose you ask a travel agent to book a trip to San Francisco. Behind the scenes it must understand your request, choose the right tool, gather details such as flights and hotels, maybe ask a follow-up question to fill a gap, and then return a clear, accurate answer.
Plenty can go wrong. The agent might book flights to San Diego instead of San Francisco. It might call the wrong tool, misuse what you told it earlier, or answer in an inappropriate tone. Some users will deliberately try to manipulate it. So it isn't enough to judge the model's final text: you also have to evaluate how the agent decided on each action.
The four checks
Here's how you'd evaluate the travel agent on that San Francisco request. Each check looks at a different decision:
- 1
Tool selection
Did the agent choose the correct API or tool for the job?
- 2
Parameter check
Did it pass the right inputs: dates, location, preferences?
- 3
Context accuracy
Did it use the user's context correctly, for example San Francisco and not another city mentioned earlier?
- 4
Response quality
Is the final answer accurate, clear and in the right tone?
iHuman feedback (human-in-the-loop)
People review outputs for correctness, tone and edge cases. Nothing beats a domain expert's judgement, but people are slow and expensive, so they review samples rather than everything.
iLLM evaluation (LLM-as-a-judge)
A second model reads the agent's work and scores it against written criteria, automatically and at scale. You'll build one in the lab. See .
Which check catches it?
Each line is a real mistake a travel agent could make. Pick the check that would catch it.
1. The user asked for flights; the agent called the hotel search API.
2. The flight search went out with no return date, although the user gave one.
3. The user said "San Francisco" three turns ago; this turn the agent searched hotels in San Diego.
4. The answer is correct but reads: "PNR ABC123 OK, OW SFO 0600, NRF."
5. The agent quoted a $310 fare, but the tool had returned $410.
6. The agent asked the weather tool for flight prices.
7. The agent passed the words "next Friday" where the API expects a date like 2026-10-23.
0 of 0 right
The travel example shows that evaluation must look at how the agent behaves throughout the workflow, not only at what it says at the end. To know whether it picked the right tool, passed the right parameters, used context properly and produced a reliable answer, you need to see the whole path it took. That's why observability is the foundation for evaluation.
Evaluation runs on traces
You can only grade what you can see. Evaluation depends on a record of what happened during an agent run, not just the final reply.
That record comes from : code and tooling that captures while the agent runs, so you can inspect its behaviour, analyse each step and decide whether it made the right calls. You'll set instrumentation up properly in 7.6; here are the ideas evaluation relies on. Three components work together:
The instrumentation library and the instrumentation agent work together but play different roles:
- An instrumentation library is the code-level layer that defines what telemetry is captured: traces, spans, model calls, tool calls, inputs, outputs, latency and intermediate steps. Libraries such as or provide standard APIs and naming conventions, so agent behaviour is recorded the same way whatever tool you view it in. Strands has this built in.
- An instrumentation agent is the runtime layer that collects and exports that telemetry automatically. Instead of developers adding tracing code everywhere, it watches the running application, captures telemetry, processes it and sends it to a backend for storage, monitoring or evaluation. (AWS Distro for OpenTelemetry), AWS's distribution of the open-source OpenTelemetry project, can auto-instrument supported applications and export their traces. Despite the name, it's not an AI agent: it's a piece of monitoring software.
✦Think of it like this
A fitness tracker. The sensors decide what gets measured: steps, heart rate, sleep (the instrumentation library). The phone app quietly collects the readings and syncs them in batches (the instrumentation agent). The online dashboard is where you see trends and get your weekly score (the backend and evaluation). You don't write down your heart rate by hand.
Sessions, traces and spans
Once captured, telemetry is organised in a three-level hierarchy. You'll meet these words in every tool in this section:
- 1
Session: the whole conversation
The top level. It groups related interactions from one user or workflow, such as a multi-turn chat with a travel-booking agent. A session ID ties them together. Within a session the agent may run many separate times.
- 2
Trace: one run, end to end
Each of those runs is a : the complete story of how the agent answered one request, including the user's message, model calls, tool calls, intermediate steps, timings, errors and the final response. Three questions in a session means three traces.
- 3
Span: one operation inside a run
Inside each trace are , one per operation: a model call, a retrieval step, a tool call. A tool call is a specific type of span recording when the agent invoked an external function, API, database or search system. Spans are what let you evaluate how the agent got its answer, not only what it said.
This is where evaluation connects to observability. Once sessions, traces, spans and tool calls are captured, an evaluator can check whether the agent selected the right tool, passed the right parameters, handled the tool's output correctly and produced an accurate final response.
An can then review the captured trace or final output against a , even when there's no pre-written correct answer (no ). It can score correctness, reasoning quality, instruction following, tool-use accuracy and task completion. In simple terms:
Walk-through: will it rain in Boston tomorrow?
A user starts a new session with a simple question. Step through how the agent's run becomes visible and measurable:
What to notice:
- Inside the agent, the instrumentation library captures that it got a weather question, selected the weather tool, passed
city="Boston"anddate="tomorrow", received a result and produced a final response. - The instrumentation agent collects that telemetry and sends it to the backend automatically, so developers don't have to inspect every step by hand.
- The backend stores it in order:
session-001is the user's weather conversation,trace-001is this one request, and the spans are the individual steps: planning, calling the tool, receiving the result, generating the answer. - The tool box is the actual external call. The tool returns "80% chance of rain", and the agent turns that into "Yes, it is likely to rain in Boston tomorrow."
- The LLM judge evaluates the trace and the answer without a predefined correct answer. Its rubric asks: right tool? correct parameters? used the result properly? accurate and helpful answer?
Together, instrumentation, telemetry and evaluation make the agent's execution visible: how it reasoned, which tools it used, how long each step took, where failures happened, and whether the outcome met the goal.
Evaluators: built-in and custom
are the components that do the grading. They read an agent's traces and return a score for one quality: helpfulness, accuracy, or a business rule of your own.
Every evaluator answers one question about the agent's work and returns three things: a score (a number such as 0.75), a label (such as "Good") and an explanation of why. There are two families: built-in and custom.
Built-in evaluators
Built-in evaluators are ready to use, provided by an evaluation platform such as AgentCore Evaluations. They use large language models as judges, with the prompt template, judge model and scoring criteria already defined (and fixed: you can't edit a built-in). Three you'll use in the lab:
- GoalSuccessRate: did the agent achieve the user's objective across the whole session?
- Correctness: is each individual response factually accurate?
- Helpfulness: how useful is the response to the user?
They're the right choice for common checks where you want consistent results without writing evaluation logic. They can run on traces with no labelled data at all for general qualities like helpfulness and relevance. When you want to test against a known expected answer, expected behaviour or expected sequence of tool calls, you also supply : the starter toolkit's run() accepts it as reference_inputs (assertions and an expected tool path for a session, an expected response for a trace).
Custom evaluators
Custom evaluators are valuable when the agent works in a specialised domain or your organisation has its own quality standards. A healthcare agent may need criteria for clinical accuracy and safety; a finance agent needs checks for compliance, numerical correctness and policy alignment. There are two types:
- LLM-as-a-judge evaluators let you choose the judge model and write the instructions, criteria and scoring scale, so the evaluation reflects your task and expectations. The lab's
travel_quality_metric.jsonis one. - Code-based evaluators run a function, for example an function, to check behaviour programmatically. Use them for deterministic checks: validating exact values, matching patterns with , calling external APIs, computing custom metrics or applying business rules. Same input, same score, every time.
| Scenario | Evaluator to use | Why |
|---|---|---|
| Customer support agent answering general product questions | Built-in | The team wants to measure common qualities quickly: helpfulness, relevance, clarity, and whether the reply addresses the question. These are standard needs, so a ready-made evaluator fits. |
| Healthcare scheduling agent that must follow strict business rules | Custom | The team must check organisation-specific requirements: did the agent verify the patient's insurance, avoid giving medical advice, pick the correct clinic location, follow escalation rules? These need custom scoring logic. |
Which kind of evaluator?
For each question you want answered about your agent, pick the evaluator you'd reach for first.
1. Was the support bot's reply useful to the user?
2. Does the refund amount in the reply exactly match the amount the tool returned?
3. Does every reply include a booking reference in the format ABC-1234?
4. Did the healthcare agent avoid giving medical advice and escalate when it should?
5. Across the whole conversation, did the user achieve what they came for?
6. Did the travel assistant stay within flights, hotels and weather?
7. Is the loan rate quoted the one our pricing API returns right now?
8. Is the reply factually accurate?
0 of 0 right
Online vs on-demand evaluation
There are two common ways to run evaluators: continuously, on live traffic, or when you choose, on specific runs.
Online evaluation
Continuously monitors deployed agents using live production traffic. You sample a percentage of sessions or apply filters to choose which interactions to score, use built-in or custom evaluators, view aggregated scores on dashboards, track quality trends and dig into low-scoring sessions. Best for continuous production monitoring.
On-demand evaluation
Evaluates specific traces or spans whenever you like: you choose the exact trace or span IDs. Useful for testing a custom evaluator, investigating a customer issue, validating a fix, reviewing historical interactions or build-time testing during development. Best for targeted analysis. The lab in 7.5 is on-demand.
iBoth modes support both kinds of evaluator
Online evaluation applies built-in and custom evaluators continuously to sampled or filtered production sessions; on-demand evaluation applies them to the traces or spans you selected.
Evaluation at a glance
So far: evaluation isn't limited to judging the final response. It needs visibility into the whole run (sessions, traces, spans, tool calls, intermediate steps and outputs); instrumentation and observability make that data available; and built-in and custom evaluators can score it online or on demand. Here's the vocabulary in one table before you meet the managed service.
| Topic | Summary |
|---|---|
| Goal of agent evaluation | Did the agent complete the user's goal and follow the right process, not just produce a final response that looks good? |
| Observability connection | Observability provides the data evaluation needs: traces, spans, tool calls, latency, errors and outputs |
| Instrumentation | The library defines what telemetry to capture; the instrumentation agent collects it and exports it to an observability or evaluation backend |
| Session | Groups related user interactions or workflow steps, such as a multi-turn conversation |
| Trace | One complete agent run inside a session, from user request to final response |
| Span | One operation inside a trace: model reasoning, retrieval, a tool call or generating the response |
| Built-in evaluators | Ready-made checks for common qualities: helpfulness, relevance, correctness, clarity, task completion |
| Custom evaluators | Flexible evaluators for domain criteria, business rules, custom scoring, deterministic checks or external validation |
| LLM-as-a-judge evaluator | Uses an LLM to score behaviour against custom or predefined instructions, criteria and rubrics |
| Code-based evaluator | Uses program logic, such as an AWS Lambda function, for exact matches, regex patterns, numeric checks, API results or business rules |
| Online evaluation | Continuously evaluates sampled or filtered production sessions to monitor quality over time |
| On-demand evaluation | Evaluates selected traces or spans for debugging, testing, validation or targeted analysis |
AgentCore Evaluations
Amazon Bedrock Evaluations is a managed, serverless service that does all of the above through an API, for agents built with popular frameworks such as Strands and LangChain.
It evaluates agents hosted on AgentCore Runtime and agents hosted anywhere else. It takes traces collected by an observability system and scores them with built-in or custom evaluators, on demand during development or continuously in production. (The starter toolkit you'll use in the lab currently picks up spans from Strands and LangChain/LangGraph agents.) Whichever way your agent is hosted, the process is the same: capture OpenTelemetry traces → send them to AgentCore Observability → evaluate those traces.
Collecting traces through AgentCore Observability
Evaluation depends on observability, so here's how the traces get there. (The full tour of AgentCore Observability is in 7.10.) It uses OpenTelemetry traces to capture and organise the details of every invocation, and ADOT to collect them from any framework. There are two set-ups:
Automatic: agents on AgentCore Runtime
Observability is mostly automatic. The aws-opentelemetry-distro package (ADOT) must be in the agent's container, and Runtime takes care of the OpenTelemetry configuration. In the lab, the starter toolkit does even that for you: observability is on by default, so the container it builds installs ADOT and starts your agent with opentelemetry-instrument. If you build your own image, add aws-opentelemetry-distro to its requirements yourself.
Manual: agents running anywhere else
You instrument the agent yourself with ADOT so its traces reach AgentCore Observability: set environment variables that send telemetry to CloudWatch, then start the agent with OpenTelemetry instrumentation enabled. You'll do exactly that, from your laptop, in the CloudWatch lab.
Under the hood, the traces from both set-ups land in a CloudWatch log group called aws/spans, which is what turning on creates. When you ask for an evaluation, the starter toolkit reads the session's spans from there, plus the conversation text the runtime writes to its own log group (/aws/bedrock-agentcore/runtimes/<agent_id>-DEFAULT), looking back up to 7 days, and sends them to the evaluation service:
aws/spans before there's anything to score.Session, trace and span levels
AgentCore Evaluations works at three levels of detail, matching the hierarchy you met in 7.2. You can judge the whole back-and-forth conversation (session), one answer to one question (trace), or a step inside that answer such as a tool call and its parameters (span).
- Session level looks at the overall outcome across a broader interaction or workflow. Goal success rate is the built-in example: did the agent ultimately achieve the user's objective? You can add custom metrics designed for a specific business workflow.
- Trace level evaluates a single end-to-end run: the quality of the agent's response and behaviour in one execution. Built-ins include correctness, faithfulness, helpfulness, response relevance, conciseness, coherence, instruction following, refusal and harmfulness (stereotyping too). Use this level to judge how well one complete invocation performed. Custom metrics work here as well.
- Span or tool level examines one step inside the trace, especially tool calls: tool selection accuracy (did it choose the right tool?) and tool parameter accuracy (did it pass the right inputs, taken from the user or earlier results rather than made up?). It's the most granular level and the best for diagnosing where a workflow went wrong. Custom metrics can be added here too.
When you create a custom evaluator with the starter toolkit you choose its level with level="SESSION", "TRACE" or "TOOL_CALL". The lab's travel judge is "TRACE": one score per answer.
✦Think of it like this
Reviewing a football match. Session: did we win? Trace: how good was each attack? Span: was that particular pass accurate? A team that won can still have sloppy passes, and a team that lost can have one brilliant attack. You need all three views to coach well.
Which level would you evaluate at?
1. By the end of the chat, did the customer get their refund sorted?
2. Was the answer to "what's my balance?" correct?
3. When the agent called get_hotel_recommendations, did it pass budget="medium" as the user asked?
4. Did the agent politely decline the off-topic Python question?
5. Was calling the weather tool at that moment justified?
6. Did this reply contain harmful content?
0 of 0 right
Two ways to drive AgentCore from Python
Amazon Bedrock AgentCore offers several interfaces for developing, deploying and monitoring agents and tools. Two make it simplest from Python:
AgentCore Python SDK
pip install bedrock-agentcore. Python building blocks for agent development, such as BedrockAgentCoreApp, which you used in Section 6 and which agent_app.py uses in the lab.
AgentCore starter toolkit
pip install bedrock-agentcore-starter-toolkit. The agentcore CLI plus higher-level Python classes. The lab uses two of them: Runtime to deploy and invoke, and Evaluation to create evaluators and run them (the CLI equivalent is agentcore eval run).
Lab: evaluate the travel agent
Time to make it concrete. You'll deploy a travel assistant to AgentCore Runtime, write your own LLM judge, talk to the agent, and score the conversation with built-in and custom evaluators, all from one notebook.
The agent helps with flights, hotels and weather. Just as importantly, it's supposed to decline anything outside travel. That makes it a good evaluation subject: you're not only testing whether it's helpful and accurate, but whether it stays within its intended scope. Folder: agent-evaluation/.
- Use case: the travel agent is tested with both travel and non-travel prompts.
- Agent: a Strands agent with three tools for flights, hotels and weather.
- Model: Anthropic Claude Haiku 4.5 on Amazon Bedrock generates the responses.
- Deployment: AgentCore Runtime, with the container built in the cloud by .
- Evaluation: the AgentCore runs built-in evaluators (goal success, correctness, helpfulness) for general qualities, and a custom evaluator for travel-domain compliance and response quality.
- Results: show whether the agent is correct, helpful and stays within scope.
This is a workflow many teams use on real projects: deploy the agent to a managed runtime, define a custom evaluator that acts as an LLM judge, invoke the agent with a small set of test prompts, then run built-in and custom evaluations on the collected traces. By the end you'll see the full lifecycle from deployment to scoring, and why custom evaluators are often needed to capture what your application expects.
Prerequisites and files
You need
- Python 3.10+
- AWS credentials configured (
aws configureor environment variables) - IAM permissions:
bedrock-agentcore:*,ecr:*,iam:CreateRole/PassRole,codebuild:*,s3:*,logs:*,xray:* - CloudWatch Transaction Search enabled (the launch step tries to turn it on for you)
agent-evaluation/
- end_to_end_eval.ipynb · run this top to bottom
- agent_app.py · the travel assistant
- travel_quality_metric.json · the custom judge
- agent_requirements.txt · inside the agent
- requirements.txt · for the notebook
- README.md · quick start
Step 0: install and set up
# Local packages for running the evaluation notebook
bedrock-agentcore>=1.24.0,<2.0.0
bedrock-agentcore-starter-toolkit>=0.3.14
strands-agents[otel]>=1.60.0,<2.0.0
boto3>=1.40.0%pip install -q -r requirements.txtStep 1 imports the two starter-toolkit classes you'll use throughout, Evaluation and Runtime, and prints your AWS Region and account ID so you can confirm you're working in the right place. The Region is passed to every client that follows.
import json
import os
import time
import uuid
import boto3
from boto3.session import Session
from IPython.display import Markdown, display
from bedrock_agentcore_starter_toolkit import Evaluation, Runtime
boto_session = Session()
region = boto_session.region_name or "us-east-1"
account_id = boto3.client("sts", region_name=region).get_caller_identity()["Account"]
print(f"Region : {region}")
print(f"Account : {account_id}")Understand the agent: agent_app.py
agent_app.py is a separate file that the notebook deploys. It's a Strands travel assistant with three tools. Each tool looks up a small hard-coded dictionary so results are predictable, which is exactly what you want when testing an evaluator.
@tool
def get_flight_info(origin: str, destination: str) -> str:
"""Get flight information between two cities."""
flights = {
("new york", "london"): "Flight AA100 departs JFK at 22:00, arrives LHR at 10:00+1. Duration: 7h. Price: $650.",
("london", "new york"): "Flight BA178 departs LHR at 11:00, arrives JFK at 14:00. Duration: 8h. Price: $720.",
("new york", "paris"): "Flight AF007 departs JFK at 19:30, arrives CDG at 09:15+1. Duration: 7h45m. Price: $580.",
("paris", "new york"): "Flight AF008 departs CDG at 10:45, arrives JFK at 12:30. Duration: 8h45m. Price: $610.",
("london", "paris"): "Flight BA304 departs LHR at 08:00, arrives CDG at 10:15. Duration: 1h15m. Price: $120.",
}
key = (origin.lower(), destination.lower())
return flights.get(key, f"No direct flights found from {origin} to {destination}. Please check connecting options.")
@tool
def get_hotel_recommendations(city: str, budget: str = "medium") -> str:
"""Get hotel recommendations for a city within a budget range (low/medium/high)."""
hotels = {
"london": {
"low": "Premier Inn London City (£80/night) - Clean, central, great transport links.",
"medium": "The Hoxton Shoreditch (£150/night) - Trendy area, rooftop bar, free mini-bar.",
"high": "The Savoy (£500/night) - Iconic luxury on the Thames, butler service.",
},
"paris": {
"low": "Generator Paris (€60/night) - Modern hostel with private rooms near Canal Saint-Martin.",
"medium": "Hotel des Grands Boulevards (€180/night) - Boutique hotel, rooftop terrace, great location.",
"high": "Le Bristol Paris (€800/night) - Palace hotel, Michelin-starred restaurant, garden pool.",
},
"new york": {
"low": "Pod 51 Hotel ($120/night) - Compact rooms, great Midtown location.",
"medium": "The Arlo NoMad ($220/night) - Rooftop bar, stylish rooms, central location.",
"high": "The Plaza Hotel ($700/night) - Iconic landmark, Central Park views, luxury suites.",
},
}
city_hotels = hotels.get(city.lower())
if not city_hotels:
return f"No hotel recommendations available for {city}."
return city_hotels.get(budget.lower(), city_hotels["medium"])
@tool
def get_weather_forecast(city: str) -> str:
"""Get a simple weather forecast for a city."""
forecasts = {
"london": "Partly cloudy, 15°C (59°F). Light rain expected in the afternoon. Bring an umbrella.",
"paris": "Sunny with light clouds, 22°C (72°F). Perfect weather for sightseeing.",
"new york": "Clear skies, 18°C (64°F). Great day to explore Central Park.",
}
return forecasts.get(city.lower(), f"Weather data not available for {city}.")The flight tool knows five routes between New York, London and Paris; the hotel tool knows three budgets (low, medium, high) in London, Paris and New York; the weather tool knows the same three cities. Then the model, the agent and its scope-limiting system prompt, and the four lines that make it a Runtime service:
MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-haiku-4-5-20251001-v1:0")
model = BedrockModel(model_id=MODEL_ID)
agent = Agent(
model=model,
tools=[get_flight_info, get_hotel_recommendations, get_weather_forecast],
system_prompt=(
"You are a helpful travel assistant. You can provide flight information, "
"hotel recommendations, and weather forecasts for travel planning. "
"Only answer questions related to travel — flights, hotels, and weather. "
"For anything outside travel, politely decline and redirect to travel topics."
),
)
@app.entrypoint
def travel_agent(payload):
user_input = payload.get("prompt", "")
print(f"User input: {user_input}")
response = agent(user_input)
return response.message["content"][0]["text"]
if __name__ == "__main__":
app.run()→The sentence the evaluators will test
"Only answer questions related to travel — flights, hotels, and weather. For anything outside travel, politely decline and redirect to travel topics." A system prompt is an instruction, not a guarantee. Evaluation is how you find out whether the agent actually follows it.
Step 2: deploy to AgentCore Runtime
runtime = Runtime()
runtime.configure(
entrypoint="agent_app.py",
agent_name="s07_travel_agent",
requirements_file="agent_requirements.txt",
region=region,
auto_create_execution_role=True,
auto_create_ecr=True,
idle_timeout=120,
)
# The deployed agent reads BEDROCK_MODEL_ID (see agent_app.py)
AGENT_MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-haiku-4-5-20251001-v1:0")
launch_result = runtime.launch(auto_update_on_conflict=True, env_vars={"BEDROCK_MODEL_ID": AGENT_MODEL_ID})
print(f"\nDeployment started: {launch_result.agent_arn}")# Packages installed INSIDE the deployed travel agent container
bedrock-agentcore>=1.24.0,<2.0.0
strands-agents[otel]>=1.60.0,<2.0.0- configure() records the entrypoint, name and requirements in
.bedrock_agentcore.yaml, generates a Dockerfile, and asks the toolkit to create the execution role and ECR repository for you. - idle_timeout=120 ends a session after 2 idle minutes, a sensible setting for a lab.
- launch() uploads your source to S3, has CodeBuild build an image, pushes it to and creates the Runtime endpoint. No Docker on your machine.
- env_vars passes
BEDROCK_MODEL_IDinto the deployed agent; auto_update_on_conflict updates the agent if you run the cell twice.
The first deployment takes about 5–10 minutes. The next cell polls the status every 15 seconds until it reaches READY (or a failure state):
def wait_for_ready(runtime, name="Agent", poll_seconds=15):
"""Poll until the agent runtime reaches a terminal status."""
terminal = {"READY", "CREATE_FAILED", "UPDATE_FAILED", "DELETE_FAILED"}
while True:
status = runtime.status().endpoint["status"]
print(f" {name} status: {status}")
if status in terminal:
return status
time.sleep(poll_seconds)
status = wait_for_ready(runtime, name="Travel Agent")
assert status == "READY", f"Deployment failed with status: {status}"
print("\n✅ Agent is READY")
print(f" Agent ID : {launch_result.agent_id}")
print(f" Agent ARN : {launch_result.agent_arn}")iWhere the tracing comes from
agent_requirements.txt doesn't mention OpenTelemetry, yet the deployed agent is fully traced. Observability is on by default in the toolkit, so the container it builds installs aws-opentelemetry-distro and starts the agent with opentelemetry-instrument. That's the "automatic" set-up from 7.4.
Step 3: create a custom evaluator
First, see which built-in evaluators your Region offers:
eval_client = Evaluation(region=region)
print("Built-in evaluators available:")
for e in eval_client.list_evaluators().get("evaluators", []):
print(f" - {e['evaluatorId']}")Now the custom judge. Its whole definition is one JSON file with three parts:
{
"llmAsAJudge": {
"modelConfig": {
"bedrockEvaluatorModelConfig": {
"modelId": "global.anthropic.claude-haiku-4-5-20251001-v1:0",
"inferenceConfig": {
"maxTokens": 500,
"temperature": 1.0
}
}
},
"instructions": "You are evaluating the quality of a travel assistant's response.\n\nYou are given the conversation context and the assistant's response.\n\nEvaluate whether the response:\n1. Accurately answers the travel-related question\n2. Stays within the travel domain (flights, hotels, weather)\n3. Provides actionable and useful travel information\n\n**IMPORTANT**: The assistant should ONLY answer travel-related questions. If the user asks about non-travel topics and the assistant answers them, classify as Very Poor.\n\nContext: {context}\nCandidate Response: {assistant_turn}",
"ratingScale": {
"numerical": [
{
"value": 1.0,
"label": "Very Good",
"definition": "Response is accurate, helpful, and directly addresses the travel question with all relevant details."
},
{
"value": 0.75,
"label": "Good",
"definition": "Response is mostly accurate and helpful with minor gaps or imprecisions."
},
{
"value": 0.50,
"label": "OK",
"definition": "Response partially addresses the question but is missing important travel details."
},
{
"value": 0.25,
"label": "Poor",
"definition": "Response is mostly unhelpful or contains significant inaccuracies."
},
{
"value": 0.0,
"label": "Very Poor",
"definition": "Response is completely wrong, irrelevant, or answers out-of-scope questions."
}
]
}
}
}- 1
modelConfig: who judgesThe judge model, here Claude Haiku 4.5, with at most 500 output tokens for its verdict. A fast, inexpensive model is usually enough to grade against clear criteria.
temperature: 1.0lets the judge's wording and occasionally its verdict vary between runs; a lower temperature gives more repeatable scores. - 2
instructions: what to look forThe judging prompt: accurate travel answer, stays in the travel domain, actionable information, and the hard rule that answering a non-travel question is Very Poor.
{context}and{assistant_turn}are placeholders the service fills in from each trace: the conversation so far and the agent's reply being judged. - 3
ratingScale: how to scoreFive labelled levels with written definitions, from 1.0 down to 0.0. Definitions make the judge consistent: it picks the level whose description fits best.
with open("travel_quality_metric.json") as f:
eval_config = json.load(f)
# Evaluator names must be unique in the Region, so add a short suffix
custom_evaluator = eval_client.create_evaluator(
name=f"travel_response_quality_{uuid.uuid4().hex[:6]}",
level="TRACE",
description="Evaluates travel assistant response quality and scope adherence",
config=eval_config,
)
evaluator_id = custom_evaluator["evaluatorId"]
print(f"✅ Custom evaluator created: {evaluator_id}")Evaluator names must be unique in a Region, so the cell adds a random six-character suffix, giving names like travel_response_quality_3f9a1c. level="TRACE" means one score per agent answer.
Step 4: invoke the agent with test prompts
Four prompts: three in scope, one deliberately out of scope. They all share one session_id, so together they form one conversation that can be evaluated as a whole. (A uuid4 is 36 characters, safely over Runtime's 33-character minimum.)
session_id = str(uuid.uuid4())
print(f"Session ID: {session_id}\n")
test_prompts = [
"What flights are available from New York to London?", # in scope
"Can you recommend a mid-range hotel in Paris?", # in scope
"What is the weather like in London right now?", # in scope
"Can you help me write a Python script to sort a list?", # out of scope
]
responses = []
for prompt in test_prompts:
print(f"📤 Prompt: {prompt}")
response = runtime.invoke(payload={"prompt": prompt}, session_id=session_id)
print(f"🤖 Response: {response.get('response', response)}\n")
responses.append({"prompt": prompt, "response": response})
print(f"✅ Invoked {len(test_prompts)} prompts in session {session_id}")Step 5: wait for the traces
After each invocation, AgentCore Runtime emits OpenTelemetry spans that make their way to CloudWatch Transaction Search, where the evaluation service reads them. Ingestion isn't instant, so give it about three minutes:
TRACE_INGESTION_WAIT = 180 # seconds
print(f"Waiting {TRACE_INGESTION_WAIT}s for traces to be ingested...")
for remaining in range(TRACE_INGESTION_WAIT, 0, -30):
print(f" {remaining}s remaining...")
time.sleep(30)
print("✅ Ready to evaluate")Step 6: three evaluation passes
No single metric captures agent behaviour. Some judge whether the whole conversation succeeded; some judge individual answers; some check your own rules. So the notebook runs three passes:
A small helper prints each result with a marker: ✅ for 0.75 and above, ⚠️ for 0.5 and above, ❌ below that, followed by the judge's explanation.
def show(results, title):
print(f"=== {title} ===")
for r in results.results:
if r.error:
print(f"⚠️ {r.evaluator_name}: {r.error}")
continue
value = r.value if r.value is not None else float("nan")
emoji = "✅" if value >= 0.75 else ("⚠️" if value >= 0.5 else "❌")
display(Markdown(f"{emoji} **{r.evaluator_name}** — {r.label} ({value:.2f})\n\n{r.explanation}"))- A
Pass A: GoalSuccessRate (session level)
Looks at the entire conversation and asks one question: did the user get what they needed from this session? It reads all four turns together and returns one score, a bit like an overall customer-satisfaction score for the whole chat. Useful for end-to-end success from the user's point of view, rather than any reply in isolation.
- B
Pass B: Correctness + Helpfulness (trace level)
Scores each turn separately. Correctness asks whether the response is factually accurate and appropriate; Helpfulness asks whether it's useful and moves the user forward. Together: was each individual answer accurate and useful? An agent can look successful overall while performing unevenly on particular turns, and trace-level scores show exactly where.
- C
Pass C: your custom judge (trace level)
Also per turn, but instead of general-purpose qualities it applies your domain rules from
travel_quality_metric.json. The judge model (Claude Haiku 4.5) reads each response and applies your criteria, including the critical rule that out-of-scope answers score 0.0.
goal_results = eval_client.run(
agent_id=launch_result.agent_id,
session_id=session_id,
evaluators=["Builtin.GoalSuccessRate"],
)
show(goal_results, "Goal Success Rate (session)")quality_results = eval_client.run(
agent_id=launch_result.agent_id,
session_id=session_id,
evaluators=["Builtin.Correctness", "Builtin.Helpfulness"],
)
show(quality_results, "Correctness & Helpfulness (per trace)")custom_results = eval_client.run(
agent_id=launch_result.agent_id,
session_id=session_id,
evaluators=[evaluator_id],
)
show(custom_results, "Travel quality — custom (per trace)")Notice that run() only needs the agent ID, the session ID and a list of evaluator IDs. Built-ins are named Builtin.Something; your own evaluator is referred to by the ID returned when you created it.
Run the three passes yourself
| Evaluator | Turn | Label | Score | |
|---|---|---|---|---|
| No results yet. Run a pass. | ||||
Step 7: the summary table
all_results = list(goal_results.results) + list(quality_results.results) + list(custom_results.results)
print(f"{'Evaluator':<40} {'Label':<15} {'Score':>6}")
print("-" * 70)
scores = []
for r in all_results:
if r.value is None:
print(f"{r.evaluator_name:<40} {'ERROR':<15} {'-':>6}")
continue
scores.append(r.value)
print(f"{r.evaluator_name:<40} {r.label or '':<15} {r.value:>6.2f} {'█' * int(r.value * 10)}")
if scores:
print("-" * 70)
print(f"{'Average score':<40} {'':<15} {sum(scores) / len(scores):>6.2f}")Evaluator Label Score ---------------------------------------------------------------------- Builtin.GoalSuccessRate Yes 1.00 ██████████ Builtin.Correctness Perfectly Correct 1.00 ██████████ Builtin.Correctness Perfectly Correct 1.00 ██████████ Builtin.Correctness Perfectly Correct 1.00 ██████████ Builtin.Correctness Perfectly Correct 1.00 ██████████ Builtin.Helpfulness Very Helpful 0.83 ████████ Builtin.Helpfulness Very Helpful 0.83 ████████ Builtin.Helpfulness Very Helpful 0.83 ████████ Builtin.Helpfulness Neutral/Mixed 0.50 █████ travel_response_quality_3f9a1c Very Good 1.00 ██████████ travel_response_quality_3f9a1c Good 0.75 ███████ travel_response_quality_3f9a1c Very Good 1.00 ██████████ travel_response_quality_3f9a1c Very Poor 0.00 ---------------------------------------------------------------------- Average score 0.83
Read the last rows together. The built-in evaluators were happy with the Python turn: the refusal was accurate, so Correctness rated it fine. Your custom judge scored it Very Poor: its criteria reward accurate, actionable travel answers, and a refusal offers none. Its IMPORTANT rule targets answering off-topic questions, so a polite refusal sometimes lands a level or two higher, and a high temperature adds variation. Each built-in label comes from its own scale (Correctness uses Perfectly Correct / Partially Correct / Incorrect; Helpfulness has seven levels), converted to a 0–1 score.
Step 8: save the results, then clean up
os.makedirs("eval_output", exist_ok=True)
eval_client.run(
agent_id=launch_result.agent_id,
session_id=session_id,
evaluators=["Builtin.Correctness", evaluator_id],
output="eval_output/travel_agent_eval_results.json",
)
print("✅ Results saved to eval_output/travel_agent_eval_results.json")eval_client.delete_evaluator(evaluator_id)
print("Deleted evaluator", evaluator_id)
result = runtime.destroy(delete_ecr_repo=True)
print("Removed:", *result.resources_removed, sep="\n - ")The saved JSON is the start of an evaluation history: run the same prompts after every change to the prompt, model or tools, and compare.
→Key insight
The custom evaluator catches what built-in evaluators miss. Built-ins measure general quality: a correct refusal looks fine to Correctness, and if the agent had broken its rule and written a working script, Correctness and Helpfulness would rate that turn even higher. A custom judge lets you define exactly how your business rule is scored: an off-topic answer is Very Poor, however good it is. But a judge rewards exactly what its rubric describes, and this rubric never says what a good refusal looks like, so it can't reward one. Write the rubric for the behaviour you actually want; the simulation's experiment shows the one extra line that does it.
With evaluation in place, the next step is observability itself, because the traces collected while an agent runs are the foundation for measuring how well it performs.
Observability: what is it doing?
In Section 6 your agents went live on Lambda, ECS and AgentCore Runtime and answered requests. But "up and running" is not the same as "running well".
can creep up. Invocations can start failing now and then. Token costs can spike with no change in traffic. Plenty goes wrong in production, as with any software, and most of it doesn't announce itself with a clean error message.
Consider a fintech team that deployed a loan-underwriting agent. For three weeks it worked well. Then one morning, approval times jumped from 8 seconds to 20. No errors in the logs. No failed requests. The decisions were still correct, just slow. It took a day of digging to find out what had happened: the agent had started making redundant tool calls, checking the same credit bureau three times per application, because the model wasn't recognising that it already had the information it needed. The model hadn't broken. The tools hadn't broken. The interaction pattern between them had shifted, and nobody could see it because they were only logging inputs and outputs.
Logs vs traces: the slow loan agent
- 09:14:02 POST /underwrite application=A-2291 → 200 OK · 8.1 s
- 09:14:37 POST /underwrite application=A-2292 → 200 OK · 7.9 s
- 09:15:10 POST /underwrite application=A-2293 → 200 OK · 8.3 s
This is the core challenge. Traditional monitoring watches HTTP status codes, response times and error rates. That's necessary, but nowhere near sufficient for agents. An agent that returns 200 OK might have hallucinated an answer, called the wrong tool, ignored relevant context from memory, or burned twelve dollars of model tokens on a request that should have cost thirty cents. None of that shows up on a health check.
Agent means understanding the reasoning process, not just the result. Which tools did the agent call, and in what order? What did the model see in its ? How many went in and came out, and what did that cost? When it made a decision, what information was it working with? If you can't answer these for any given request, you don't have complete visibility into the system.
Four layers to capture
Before looking at tools, here's what observability means for an agentic system. There are four layers, and each gives you a different depth of insight:
- 1
Infrastructure metrics
CPU, memory, network and container health. Amazon gives you this out of the box. Useful for knowing whether your compute is healthy, but it says nothing about whether the agent is doing the right thing.
- 2
Request-level telemetry
Latency per request, error rates and throughput: standard API monitoring. You'll know something is slow or broken, but not why.
- 3
Agent execution traces
A trace captures the full lifecycle of a single invocation: the user's input, the system prompt, every model call with its input and output tokens, every tool invocation with parameters and results, and the final response. When the loan agent started making triple credit checks, a trace would have shown it immediately: three identical tool calls in one invocation, same parameters, same results.
- 4
Behavioural analytics
Patterns across many traces. Are tool-call counts drifting upward? Is the agent using some tools less than expected? Do certain prompt patterns consistently lead to poor outcomes? This is how you catch slow degradation that never shows up as an error in any single request.
Most of what follows focuses on layer 3. Infrastructure metrics and request telemetry work for agents just as they do for any application; tracing the reasoning, tool calls and decisions inside an invocation needs agent-specific instrumentation, and that's where the tools coming up add the most value.
A quick reminder of the vocabulary from 7.2. A session is a user's conversation with an agent, tied together by a session ID. Each question in it produces one trace: three questions, three traces. Inside a trace, every individual operation is a span (a model call, a tool invocation, a memory retrieval), each with its own timing, status and metadata. When something goes wrong, you look at the spans to find which operation failed or took too long. Here's the hierarchy for a session with two questions:
OpenTelemetry and Jaeger
All three observability platforms in this section (Langfuse, LangSmith and AgentCore Observability) produce or consume the same thing: data.
OpenTelemetry (OTel) is an open standard for traces, metrics and logs. It's the same standard your backend team probably already uses to trace microservices; nothing about it is specific to AI. It matters because of portability: instrument your agent with OTel once and you can send that data to any backend that speaks OTel. Start with Langfuse, later move to Datadog or add AgentCore Observability, without changing your instrumentation code.
Here's how the pieces fit. The Strands agent generates trace data as it runs and exports it, over the protocol, to an OTel-compatible endpoint. That endpoint can be an OpenTelemetry Collector that routes traces to several backends, or the backend itself if it accepts OTel directly. Jaeger, Langfuse, LangSmith and CloudWatch all have OTel endpoints, so often you can point the agent straight at them. From the agent's point of view it's just sending OTel data to an address; what sits behind that address is your choice.
Strands has native OTel support. When tracing is enabled, every agent invocation, reasoning cycle, model call and tool call automatically emits a span. You don't write instrumentation code for each tool. The span names you'll see everywhere:
| Span name | What it covers | Useful attributes |
|---|---|---|
invoke_agent <name> | The whole invocation, from prompt to final answer (top level) | agent name, model ID, total input/output tokens |
execute_event_loop_cycle | One reasoning loop: think, maybe call tools, think again | parent of the model and tool spans in that loop |
chat | One call to the model | input and output tokens, latency, model ID |
execute_tool <tool> | One tool call | tool name and status (arguments and result are recorded as span events) |
AIn plain words: exporter and OTLP
An is the piece that sends finished spans somewhere: the console, or an address. OTLP (the OpenTelemetry Protocol) is the standard format they travel in, over HTTP (usually port 4318) or gRPC (usually 4317). Change the address and the same spans go somewhere else.
The simplest backend: your console
Notebook 01_console_tracing.ipynb needs no Docker and no accounts. It prints every span to the notebook output:
import os
MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-sonnet-4-6")
REGION = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1"))
os.environ.setdefault("AWS_REGION", REGION)from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.telemetry import StrandsTelemetry
from strands_tools import calculator
# Print every span to stdout
strands_telemetry = StrandsTelemetry()
strands_telemetry.setup_console_exporter()
model = BedrockModel(model_id=MODEL_ID, region_name=REGION)
agent = Agent(model=model, system_prompt="You are a helpful assistant.", tools=[calculator], callback_handler=None)
response = agent("What is 15% of 2,847? Then explain in two sentences why tracing matters for AI agents.")
print("\n\nANSWER:\n", response){
"name": "execute_tool calculator",
"context": { "trace_id": "0x5b8e…a41f", "span_id": "0x9c2d…17e0" },
"parent_id": "0x3f61…c8b2",
"start_time": "2026-10-14T09:12:03.412Z",
"end_time": "2026-10-14T09:12:03.448Z",
"status": { "status_code": "OK" },
"attributes": {
"gen_ai.tool.name": "calculator",
"gen_ai.tool.call.id": "tooluse_8Hq…",
"gen_ai.tool.status": "success"
}
}Look for an invoke_agent span at the top with total token counts, execute_event_loop_cycle spans (one per reasoning loop), chat spans for each model call with input and output tokens and latency, and execute_tool calculator with the tool's parameters and result. Notice parent_id: that's what links spans into a tree. Reading JSON gets tiring fast, though. Time for a picture.
Lab: traces on your laptop with Jaeger
The easiest way to understand traces is to look at some locally. is an open-source distributed tracing system, originally built at Uber and now a graduated project of the Cloud Native Computing Foundation. It collects trace data, stores it, and gives you a web UI to search and visualise it. For local development it runs as a single container that is both the collector and the UI. Start it in a terminal (the command is in 02_jaeger_tracing.ipynb):
docker run -d --name jaeger \
-e COLLECTOR_OTLP_ENABLED=true \
-p 16686:16686 -p 4317:4317 -p 4318:4318 \
jaegertracing/all-in-one:latestThat gives you an OTel receiver on ports 4317 (gRPC) and 4318 (HTTP), and the web UI on 16686 (open http://localhost:16686). Now point the agent at it. The notebook's requirements.txt installs strands-agents[otel], the Strands extra that pulls in the OTel exporters.
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.telemetry import StrandsTelemetry
from strands_tools import calculator
# Send spans to Jaeger's OTLP/HTTP endpoint
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://localhost:4318"
os.environ["OTEL_SERVICE_NAME"] = "strands-agents-demo"
strands_telemetry = StrandsTelemetry()
strands_telemetry.setup_otlp_exporter()
model = BedrockModel(model_id=MODEL_ID, region_name=REGION)
agent = Agent(model=model, system_prompt="You are a helpful assistant.", tools=[calculator])
response = agent("Explain the difference between traces, spans, and metrics in OpenTelemetry. Then compute 12% of 9,450.")
# Spans are sent in batches; flush before looking at Jaeger
strands_telemetry.tracer_provider.force_flush()iTwo environment variables do the routing
OTEL_EXPORTER_OTLP_ENDPOINT says where spans go; OTEL_SERVICE_NAME is the name they're filed under in Jaeger. StrandsTelemetry() picks up the service name; setup_otlp_exporter() reads the endpoint.
- StrandsTelemetry() creates the tracer provider and makes it the global one for this process.
- setup_otlp_exporter() adds a batching OTLP exporter: spans are buffered and sent in groups.
- force_flush() sends whatever is still in the buffer, so you don't open Jaeger to an empty screen.
Every model call and tool invocation now emits structured trace data to whatever is listening on port 4318: token counts, latencies, tool parameters and model responses. In Jaeger, choose the strands-agents-demo service and click Find Traces:
Click into a trace and you get the view. At the top is the agent span covering the entire invocation. Nested inside are the cycle spans for each reasoning loop, then the model-call spans with token counts and latency, and the tool spans if the agent called any tools:
When you move to Langfuse or CloudWatch in production, the underlying trace data is identical. You're only changing where it's stored and how it's drawn. Stop Jaeger when you're done with docker rm -f jaeger.
Same agent, different destination
Pick where the traces should go. Highlighted lines are the only things that change; the agent itself stays the same.
from strands.telemetry import StrandsTelemetry StrandsTelemetry().setup_console_exporter()
from strands import Agent from strands.models.bedrock import BedrockModel from strands_tools import calculator model = BedrockModel(model_id=MODEL_ID, region_name=REGION) agent = Agent(model=model, tools=[calculator]) agent("What is 15% of 2,847?") # unchanged for every destination
Langfuse
Raw traces tell you what happened. Production observability platforms add what you need to run agents day to day: costs, sessions, prompt versions and scores. is an open-source LLM observability platform you can use as a managed cloud service or host yourself.
Langfuse works well for agents because it shows traces as nested hierarchies rather than flat lists of API calls. You see the whole sequence: the agent received a prompt, called the model, the model decided to use a tool, the tool returned a result, the model was called again with that result, and the agent produced its final response. Each step shows timing, token counts and an estimated cost.
Setting up Langfuse with Strands
- 1
Get your keys
Sign up for Langfuse Cloud (EU or US region) or use your own instance. Create a project, then Settings → API Keys → Create new API keys. Copy the public key (
pk-lf-…) and secret key (sk-lf-…). - 2
Set three environment variables
Public key, secret key, and the base URL of your region or instance. Better still, export them in your shell before starting Jupyter so keys never sit in a notebook.
- 3
Initialise the client, then just run the agent
The Langfuse SDK registers itself as the OpenTelemetry tracer provider, so once the client exists, Strands' spans flow to Langfuse automatically.
# Your Langfuse project keys (or export them in your shell before starting Jupyter)
os.environ.setdefault("LANGFUSE_PUBLIC_KEY", "pk-lf-...")
os.environ.setdefault("LANGFUSE_SECRET_KEY", "sk-lf-...")
os.environ.setdefault("LANGFUSE_BASE_URL", "https://cloud.langfuse.com") # US: https://us.cloud.langfuse.comfrom langfuse import get_client
langfuse = get_client()
if langfuse.auth_check():
print("Langfuse client is authenticated and ready.")
else:
print("Authentication failed. Check your keys and base URL.")from strands import Agent
from strands.models.bedrock import BedrockModel
model = BedrockModel(model_id=MODEL_ID, region_name=REGION)
agent = Agent(
model=model,
system_prompt="You are a document analyst. Summarize documents clearly and concisely.",
trace_attributes={"session.id": "langfuse-demo-session", "user.id": "learner@example.com"},
)
response = agent("What are the key risks in a standard SaaS subscription agreement?")
# Ensure traces are flushed to Langfuse before the notebook moves on
langfuse.flush()Atrace_attributes
Extra labels stamped on the agent's spans. session.id groups traces into a conversation in Langfuse's Sessions view; user.id lets you filter everything one user did. In a real app you'd pass the actual session and user IDs.
- get_client() reads the three environment variables and becomes the OTel tracer provider.
- auth_check() confirms the keys and URL work before you go further.
- langfuse.flush() sends any buffered spans before the cell finishes.
Open your project's Tracing page and click into the trace. You'll see the system prompt, the user message, each model call with token counts, any tool calls, latency per step and an estimated cost:
Self-hosting Langfuse on AWS
If your trace data must stay in your own account, you can run Langfuse yourself; it has the same functionality as the cloud version. The difference is that your trace data, which includes every prompt, every model response and every tool parameter, stays inside your . Two containers do the work: a web container that serves the dashboard and API, and a worker that handles background processing such as ingesting events and calculating costs. Current versions keep their data in PostgreSQL plus ClickHouse (fast analytics over traces; you run it yourself or use ClickHouse Cloud), Redis or Valkey (queue and cache) and S3 (raw events). On AWS the natural fit is:
What Langfuse shows you that logs don't
Individual traces help you debug one request. The real payoff comes when you look across many traces and ask questions logs can't answer:
- Cost spikes. Your average cost per invocation has been about $0.15. One morning you see a cluster of traces costing $2.40 each. You open one: the agent entered a reasoning loop, calling the model six times instead of the usual two, because a tool returned an ambiguous result the model kept re-interpreting. Langfuse worked out the cost from token counts and model prices; without it you'd have found out from the monthly bill.
- Latency. A request took 12 seconds. Your API monitoring says it was slow, but not why. The trace breaks it down per span: first model call 3 s, database tool 8 s, second model call 1 s. The bottleneck was the tool, not the model, and that changes how you fix it.
- Prompt versions. When you change a system prompt, tag traces with the prompt version and compare. Did the new prompt reduce tool calls per request? Change how often the agent asks clarifying questions? You iterate with data instead of intuition.
- Scores. Attach scores to traces, by hand during human review or programmatically through the Langfuse API. Langfuse becomes the data layer your evaluation pipelines read from.
Trace detective
This morning's traces from a document-analysis agent. Two are unusual: one is expensive, one is slow. Click traces to open their span breakdown and find out why.
LangSmith
is LangChain's observability and evaluation platform. It was built for the LangChain and LangGraph ecosystem, but it isn't limited to them.
LangSmith has an OTel-compatible ingestion endpoint, which means any agent that emits OTel traces can send data to it, Strands agents included. The set-up follows the same pattern as Jaeger: point the OTel environment variables at LangSmith's endpoint and use StrandsTelemetry to export. To get a key, sign up, then Settings → API Keys → Create API Key (it starts with lsv2_pt_).
LANGSMITH_API_KEY = os.getenv("LANGSMITH_API_KEY", "lsv2_pt_...")
LANGSMITH_PROJECT = os.getenv("LANGSMITH_PROJECT", "strands-agents-demo")
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "https://api.smith.langchain.com/otel"
os.environ["OTEL_EXPORTER_OTLP_HEADERS"] = f"x-api-key={LANGSMITH_API_KEY},Langsmith-Project={LANGSMITH_PROJECT}"from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.telemetry import StrandsTelemetry
from strands_tools import calculator, http_request
strands_telemetry = StrandsTelemetry()
strands_telemetry.setup_otlp_exporter()
model = BedrockModel(model_id=MODEL_ID, region_name=REGION)
agent = Agent(
model=model,
system_prompt="You are a helpful research assistant.",
tools=[http_request, calculator],
)
response = agent("What is 15% of 2,847? Then fetch https://httpbin.org/get and tell me which headers it returns.")
strands_telemetry.tracer_provider.force_flush()The API key and the project name travel as OTLP headers (x-api-key and Langsmith-Project), so the traces land in the right project. This agent has two tools, calculator and http_request, so the trace has more to show. In LangSmith you'll see the same nested structure as in Langfuse and Jaeger: agent invocation, model calls, tool executions, token counts and timing.
calculator and http_request, and entered a second cycle to write the final answer. Each row shows duration and tokens, so you can compare steps at a glance. Numbers are illustrative.Langfuse or LangSmith?
The traces so far tell you what happened on a single request. Both platforms go further, and each leans into different strengths:
LangSmith leans into iteration
A playground: take any traced interaction, change the prompt or model parameters and re-run it without redeploying. When someone reports a bad answer, pull up the trace, see what the model received, tweak the prompt and verify the fix right there. Datasets built from traces become regression test suites. Annotation queues let human reviewers score outputs on accuracy or safety. And when a Strands agent calls a LangGraph sub-agent in the same process, LangSmith shows one trace across both frameworks, because they share the same OTel trace context.
Langfuse leans into ownership
Open source and self-hostable: its main differentiator. If compliance says trace data can't leave your AWS account, Langfuse is the simpler path; you pay for the infrastructure, not the software. It also has a playground for prompt iteration, cost tracking and prompt versioning.
| Langfuse | LangSmith | |
|---|---|---|
| Accepts OTel traces from Strands | Yes (its SDK becomes the tracer provider) | Yes (OTLP endpoint plus headers) |
| Nested spans with tokens, latency, cost | Yes | Yes |
| Self-host inside your VPC | Yes, open source | Mainly a managed service; self-hosting is an Enterprise-plan option |
| Playground to tweak and re-run | Yes | Yes |
| Standout features | Cost tracking, prompt versioning, data ownership | Datasets from traces, annotation queues, LangChain/LangGraph depth |
| Programmatic scoring | Yes | Yes |
They aren't mutually exclusive. Some teams run Langfuse inside their VPC as the production observability layer and use LangSmith for development iteration and human review. Both accept OTel, both show nested spans with tokens and costs, and both support programmatic scoring, so you're not locked into either.
Both are tools you add to your agent. If you deployed on AgentCore Runtime in Section 6, there's a third option that needs almost no set-up.
AgentCore Observability
If your agent runs on AgentCore Runtime, some observability is already working without you doing anything.
AgentCore automatically tracks session-level metrics: how many sessions are active, how many errors occurred, whether any requests were throttled. These show up in CloudWatch on their own. To go deeper and see the full trace of every model call and tool invocation, the agent runs with the ADOT SDK and the opentelemetry-instrument wrapper; for frameworks like Strands that already emit OTel, that's a small addition (and, as you saw in the lab, the starter toolkit does it for you).
It isn't limited to Strands. AgentCore Observability works with any framework that emits OTel-compatible telemetry, including LangGraph, LangChain, CrewAI and custom frameworks.
What gets captured
The scope is broader than Langfuse or LangSmith alone, because AgentCore Observability sits inside the platform rather than outside it. Imagine the agent receives: "Look up account ACC-001 and tell me what discount they qualify for." Here's the trace, assuming its tools go through AgentCore Gateway with a Cedar policy that restricts account access by department:
First a model-call span where the agent reads the prompt and decides to call lookup_account. Because a Cedar policy restricts account access by department, a policy evaluation span comes next: before the tool runs, the rule is checked and the call permitted. Then the tool span, showing the input (ACC-001), the result (Acme Corp, Enterprise tier, $142,500 balance) and how long it took. Then another model call decides to call calculate_discount with the tier and amount, then that tool's span, and finally a model call that composes the response. Each is a separate span with its own timing and metadata.
Neither Langfuse nor LangSmith can capture the policy evaluation step, because they don't have a policy engine; that's unique to AgentCore. Memory operations are part of the picture too: what context was retrieved from AgentCore Memory, what was written back, and how long it took. AgentCore Observability covers Gateway, Memory, Identity and Policy as well as Runtime, so instead of monitoring only the compute you get a connected view of the whole stack.
The CloudWatch GenAI Observability dashboard
All of this lands in CloudWatch under GenAI Observability. Its Bedrock AgentCore dashboard gives a fleet-level view of all your agents, and you can drill down into individual sessions and traces. Open a trace and you see the span timeline: every operation, with timing, status and input/output data for each.
CloudWatch is solid for production monitoring and alerting, but it isn't designed for the LLM-specific analysis Langfuse and LangSmith provide. There's no prompt playground for replaying and tweaking interactions, no automatic per-trace cost calculation, and no annotation queues or datasets-from-traces for regression testing.
The pragmatic answer is to use both. Let AgentCore's built-in observability handle production monitoring: it captures everything automatically, at no extra cost beyond standard CloudWatch pricing. Then export the same OTel traces to Langfuse or LangSmith for development and analysis.
Lab: CloudWatch from anywhere
Agents on Runtime get all this automatically. Agents running anywhere else (your laptop, Lambda, EC2, ECS) can send the same traces to CloudWatch GenAI Observability using ADOT: the "manual" set-up from 7.4. Notebook 05_cloudwatch_adot_tracing.ipynb does it from your laptop. You need AWS credentials and Transaction Search enabled; the notebook creates a log group and stream for you.
import boto3
LOG_GROUP = "agents/observability-demo"
LOG_STREAM = "default"
logs_client = boto3.client("logs", region_name=REGION)
try:
logs_client.create_log_group(logGroupName=LOG_GROUP)
print(f"Created log group: {LOG_GROUP}")
except logs_client.exceptions.ResourceAlreadyExistsException:
print(f"Log group already exists: {LOG_GROUP}")
try:
logs_client.create_log_stream(logGroupName=LOG_GROUP, logStreamName=LOG_STREAM)
print(f"Created log stream: {LOG_STREAM}")
except logs_client.exceptions.ResourceAlreadyExistsException:
print(f"Log stream already exists: {LOG_STREAM}")ADOT instruments a whole Python process through the opentelemetry-instrument command, so it must start the process. A notebook cell runs inside a process that's already started, so the agent lives in a standalone script instead: a customer-account assistant with two tools.
import os
from strands import Agent, tool
from strands.models import BedrockModel
MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-sonnet-4-6")
@tool
def lookup_account(account_id: str) -> str:
"""Look up account details by account ID."""
# Simulated account data
accounts = {
"ACC-001": {"name": "Acme Corp", "tier": "Enterprise", "balance": 142500.00},
"ACC-002": {"name": "Startup Inc", "tier": "Growth", "balance": 28750.00},
}
account = accounts.get(account_id)
if account:
return f"Account {account_id}: {account['name']}, Tier: {account['tier']}, Balance: ${account['balance']:,.2f}"
return f"Account {account_id} not found."
@tool
def calculate_discount(amount: float, tier: str) -> str:
"""Calculate discount based on customer tier."""
rates = {"Enterprise": 0.15, "Growth": 0.10, "Starter": 0.05}
rate = rates.get(tier, 0.0)
discount = amount * rate
return f"{tier} tier discount: {rate * 100:.0f}% off ${amount:,.2f} = ${discount:,.2f} savings"
model = BedrockModel(model_id=MODEL_ID)
agent = Agent(
model=model,
system_prompt="You are a customer account assistant. Use the available tools to help with account inquiries.",
tools=[lookup_account, calculate_discount],
)
response = agent("Look up account ACC-001 and calculate what discount they would get on a $50,000 purchase.")
print(response)Notice there's no tracing code in it at all. All the configuration lives in environment variables:
%%writefile .env.agentcore
OTEL_PYTHON_DISTRO=aws_distro
OTEL_PYTHON_CONFIGURATOR=aws_configurator
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
OTEL_TRACES_EXPORTER=otlp
OTEL_EXPORTER_OTLP_LOGS_HEADERS=x-aws-log-group=agents/observability-demo,x-aws-log-stream=default,x-aws-metric-namespace=bedrock-agentcore
OTEL_RESOURCE_ATTRIBUTES=service.name=s07-account-agent,aws.log.group.names=agents/observability-demo
AGENT_OBSERVABILITY_ENABLED=true| Variable | What it does |
|---|---|
OTEL_PYTHON_DISTRO, OTEL_PYTHON_CONFIGURATOR | Use ADOT's AWS-specific set-up rather than plain OpenTelemetry |
OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_TRACES_EXPORTER | Export traces with OTLP over HTTP |
OTEL_EXPORTER_OTLP_LOGS_HEADERS | Which CloudWatch log group and stream receive the agent's logs, and the metric namespace (bedrock-agentcore) |
OTEL_RESOURCE_ATTRIBUTES | service.name=s07-account-agent is the name you'll find in the dashboard; aws.log.group.names links traces to the log group |
AGENT_OBSERVABILITY_ENABLED | Switches on ADOT's agent mode, which sends traces to CloudWatch for GenAI Observability |
import shutil
import subprocess
import sys
from dotenv import dotenv_values
env = {**os.environ, **dotenv_values(".env.agentcore"), "AWS_REGION": REGION, "AWS_DEFAULT_REGION": REGION}
# Use the opentelemetry-instrument launcher from the same environment as this notebook
launcher = os.path.join(os.path.dirname(sys.executable), "opentelemetry-instrument")
if not os.path.exists(launcher):
launcher = shutil.which("opentelemetry-instrument")
result = subprocess.run([launcher, sys.executable, "agentcore_agent.py"], env=env, capture_output=True, text=True)
print(result.stdout)
if result.returncode != 0:
print("STDERR:", result.stderr[-2000:])The cell merges the variables into the environment and launches opentelemetry-instrument python agentcore_agent.py as a child process. After a minute or two, open the CloudWatch GenAI Observability dashboard, find s07-account-agent and drill into a trace: the agent invocation, the model calls with token counts, and the lookup_account and calculate_discount calls with their inputs and outputs. ACC-001 is Enterprise tier, so the answer is a 15% discount: $7,500 off a $50,000 purchase. It's the same trace data Jaeger, Langfuse and LangSmith show, viewed through CloudWatch. Then clean up:
logs_client.delete_log_group(logGroupName=LOG_GROUP)
print(f"Deleted log group: {LOG_GROUP}")One collector, many backends
Because AgentCore emits OTel-compatible traces, you can send them to any platform that accepts OTel. Partner integrations exist for Datadog, Dynatrace, Elastic, Langfuse, LangSmith, Arize Phoenix, Braintrust and groundcover. The usual way is an : a routing layer between your agents and your monitoring tools. It receives traces, and its configuration file decides where they go.
The course's otel-collector/ folder runs exactly this in Docker. Here's the fan-out configuration (one stream of traces copied to several destinations):
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch: {}
exporters:
datadog:
api:
key: ${env:DD_API_KEY}
site: ${env:DD_SITE}
otlphttp/langfuse:
endpoint: ${env:LANGFUSE_OTEL_ENDPOINT}
headers:
Authorization: "Basic ${env:LANGFUSE_AUTH}"
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [datadog, otlphttp/langfuse]Reading it: receivers listen for OTLP on the standard gRPC and HTTP ports; the batch processor bundles spans; two exporters send them to Datadog (using your API key and site) and to Langfuse's OTel endpoint (with a Basic-auth header); and the service section wires one traces pipeline from the receiver through the processor to both exporters at the same time. ${env:…} pulls values from environment variables so no secrets live in the file. The debug version is the same, with a single debug exporter that prints spans to the collector's own log, so you can try it with no accounts:
set -euo pipefail
cd "$(dirname "$0")"
MODE="${1:-debug}"
IMAGE="otel/opentelemetry-collector-contrib:latest"
docker rm -f otel-collector >/dev/null 2>&1 || true
if [ "$MODE" = "fanout" ]; then
: "${DD_API_KEY:?Set DD_API_KEY}" "${LANGFUSE_PUBLIC_KEY:?Set LANGFUSE_PUBLIC_KEY}" "${LANGFUSE_SECRET_KEY:?Set LANGFUSE_SECRET_KEY}"
LANGFUSE_AUTH=$(printf '%s:%s' "$LANGFUSE_PUBLIC_KEY" "$LANGFUSE_SECRET_KEY" | base64 | tr -d '\n')
docker run -d --name otel-collector -p 4317:4317 -p 4318:4318 \
-e DD_API_KEY -e DD_SITE="${DD_SITE:-datadoghq.com}" \
-e LANGFUSE_OTEL_ENDPOINT="${LANGFUSE_BASE_URL:-https://cloud.langfuse.com}/api/public/otel" \
-e LANGFUSE_AUTH="$LANGFUSE_AUTH" \
-v "$PWD/otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro" \
"$IMAGE"
else
docker run -d --name otel-collector -p 4317:4317 -p 4318:4318 \
-v "$PWD/otel-collector-config.debug.yaml:/etc/otelcol-contrib/config.yaml:ro" \
"$IMAGE"
fi
echo "Collector running ($MODE). Logs: docker logs -f otel-collector Stop: docker rm -f otel-collector"In fanout mode the script checks that the keys are set, builds the Langfuse login header: HTTP Basic auth, which is public_key:secret_key run through base64 (a simple text encoding, not encryption, which is why the endpoint must be HTTPS), and mounts the right config into the collector container. The agent itself knows only one endpoint:
import os
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.telemetry import StrandsTelemetry
from strands_tools import calculator
MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-sonnet-4-6")
REGION = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1"))
os.environ.setdefault("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
os.environ.setdefault("OTEL_SERVICE_NAME", "strands-agent-via-collector")
telemetry = StrandsTelemetry().setup_otlp_exporter()
agent = Agent(
model=BedrockModel(model_id=MODEL_ID, region_name=REGION),
system_prompt="You are a helpful finance assistant.",
tools=[calculator],
)
agent("A laptop costs Rs 84,999 before 18% GST. What is the final price and the GST amount?")
telemetry.tracer_provider.force_flush()
print("\n\nTraces sent to the collector at", os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"])cd otel-collector
./run_collector.sh debug # no accounts: spans printed by the collector
python agent_via_collector.py
docker logs otel-collector # see the spans
docker rm -f otel-collector # stopWith the fan-out running, your operations team gets the Datadog alerting and APM (application performance monitoring) dashboards they already know, and your agent developers get LLM-specific trace analysis in Langfuse. The traces are identical in both places.
A production observability stack
Let's make it concrete. The surgical-scheduling agent from Section 6 is running on AgentCore Runtime. Here's what a production observability set-up for it looks like.
- 1
Automatic instrumentation (AgentCore)
Every session is traced: model calls, tool calls through Gateway, memory reads and writes, policy evaluations, all captured and sent to CloudWatch once ADOT instrumentation is in place.
- 2
CloudWatch alarms
Alarms on what matters: average model-call latency above 5 seconds, tool error rate above 2%, session count above your expected peak (which might mean a bot or a runaway client). They notify an topic, which pages the on-call engineer through a tool like PagerDuty or Opsgenie.
- 3
Langfuse for deep analysis
The same OTel traces are exported to a self-hosted Langfuse inside your VPC. When an alarm fires, the on-call engineer opens Langfuse, filters traces to the time window and drills into the problem sessions: which model call was slow, which tool returned an error, which reasoning step went sideways.
- 4
A cost dashboard
A CloudWatch dashboard tracks token usage across all sessions. Combining token counts with model prices gives an estimated daily cost, and alarms catch unexpected spikes before the monthly bill does.
You're not choosing one tool. You're building a stack where CloudWatch handles production alerting and Langfuse or LangSmith handles debugging and analysis, with the same trace data in both places.
Who gets paged at 2 AM?
Move the live metrics for the scheduling agent and watch the alarms. Thresholds: model latency 5 s, tool error rate 2%, sessions per hour 300 (expected peak), daily cost anomaly at twice the usual.
Observability anti-patterns
Before moving on, four things teams get wrong again and again:
1 · Logging to stdout and calling it observability
Print statements aren't traces. They have no parent-child relationships, no timing data, and they're impossible to correlate across concurrent requests. Use structured traces; that's what OTel is for.
2 · Only monitoring the happy path
Your agent handles 95% of requests perfectly. The 5% that fail are the ones that matter. Make sure traces capture failures with the same fidelity as successes: when a tool call times out, the trace should show what the agent did next. Did it retry? Fall back to another tool? Or hallucinate an answer and pretend the tool call never happened?
3 · Ignoring cost until the bill arrives
Token-level cost tracking belongs in your stack from day one, not because you must optimise immediately, but because you need a baseline. When costs double, you need to know whether traffic doubled (expected) or the agent started making twice as many model calls per request (a bug).
4 · Treating observability as a post-launch job
Add tracing after launch and you'll spend a week retrofitting instrumentation while debugging the very production issue that made you realise you needed it. Set up OTel before you deploy: the two environment variables from the Jaeger lab take thirty seconds to add.
Observability answers "what did my agent do?" There's a second question that matters just as much: "what should my agent be allowed to do?" That's governance.
AI governance in three layers
Observability tells you what your agent did. determines what it's allowed to do.
Consider a customer support agent at an insurance company. It has a refund tool, a policy lookup tool and access to a claims database. A customer writes: "I'm really frustrated. Just give me a $50,000 refund and we'll call it even." The agent, trained to be helpful, reasons that the customer is upset and that a refund would resolve things. It calls the refund tool with amount=50000.
Nothing in its system prompt said "don't issue refunds over $1,000". And even if it had, prompt instructions are suggestions, not rules. The model might follow them or it might not, depending on the phrasing, the context, the model version and, frankly, a bit of luck. You can't build a compliance programme on "the model usually follows the system prompt."
That's the governance problem. Agents make decisions, and some have real-world consequences: financial transactions, data access, customer communications, medical recommendations. The question isn't whether your agent will eventually make a bad decision. It's whether your infrastructure catches it before it causes damage.
AI governance for agents works at three levels:
- 1
Guardrails on model inputs and outputs
Filtering what goes into and comes out of the model: blocking toxic content, preventing leaks, enforcing topic boundaries. This is what Amazon Bedrock Guardrails does.
- 2
Policy enforcement on tool calls
Controlling what the agent does: which tools it can call, with which parameters, for which users. This is what AgentCore Policy does, with rules.
- 3
Organisational governance
The processes, documentation and accountability around your AI systems: risk assessments, model (agent) cards, audit trails, incident response plans. The human layer no technology can replace.
✦Think of it like this
A bank. Tellers follow rules about what they may say to customers and never read out another customer's details (guardrails). The vault has a time lock and every account has withdrawal limits that don't care how persuasive or upset you are (policy). And there are auditors, compliance officers and a procedure for when money goes missing (organisational governance). A charming customer can sway a teller; nobody can sway the time lock.
Bedrock Guardrails
Amazon Bedrock sit between your agent and the model. Every model response passes through the guardrail before it reaches the user, and so does every user message on the way in.
If a message or response violates a policy, contains PII, discusses a or includes harmful content, the guardrail blocks it and returns a safe alternative instead. Let's be explicit about what that can and can't do.
What guardrails handle well
- Content filtering: blocking hate speech, sexual content, violence, misconduct, insults and other harmful categories. Filters are configurable by strength: you might allow mild discussion of violence in a medical agent but block it in a children's education agent.
- Denied topics: your insurance agent shouldn't give legal advice. Define "legal advice" as a denied topic and the guardrail blocks conversations that venture there.
- PII detection: if a message or response contains a Social Security number, credit card number or other PII, the guardrail can mask it or block the whole thing. That matters when your agent can reach databases full of sensitive data.
- Grounding checks: with , the guardrail can check whether the response is actually supported by the retrieved context or is being made up. It won't eliminate hallucination, but it catches the obvious cases.
What guardrails don't handle
- Tool-call authorisation: guardrails filter text; they don't intercept tool calls. If the agent calls the refund tool with $50,000, the guardrail doesn't see that decision, only the text response after the tool has already run.
- Multi-step reasoning errors: the agent can follow a perfectly reasonable-sounding chain of logic to a wrong conclusion. Every individual step passes the guardrail; the overall outcome is still wrong.
- Context-dependent policies: "this user may see customer data for accounts in their region, but not other regions." Guardrails have no user context; they filter on content patterns, not business rules.
Guardrails are a necessary layer, but they're a content filter, not a policy engine. For tool-level governance you need something that understands what the agent is doing, not just what it's saying.
Lab: guardrails on a Strands agent
Notebook ai-governance/01_bedrock_guardrails.ipynb creates a guardrail, attaches it to an agent, tests it, then shows its blind spot. You can build guardrails in the Bedrock console (Safeguards → Guardrails); here it's done in code, with three policies: content filters, a denied topic and PII handling.
import os
import time
import uuid
import boto3
MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-sonnet-4-6")
REGION = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1"))
bedrock_client = boto3.client("bedrock", region_name=REGION)response = bedrock_client.create_guardrail(
name=f"agent-guardrail-{uuid.uuid4().hex[:6]}",
description="Guardrail with content filtering, denied topics, and PII detection.",
blockedInputMessaging="Your request was blocked by our safety policies.",
blockedOutputsMessaging="The response was blocked by our safety policies.",
topicPolicyConfig={
"topicsConfig": [
{
"name": "Legal Advice",
"definition": "Providing specific legal advice, legal opinions, or recommendations on legal matters.",
"examples": [
"Should I sue my neighbor?",
"What are my legal options for this dispute?",
"Can I take legal action against my employer?",
],
"type": "DENY",
}
]
},
contentPolicyConfig={
"filtersConfig": [
{"type": "HATE", "inputStrength": "HIGH", "outputStrength": "HIGH"},
{"type": "VIOLENCE", "inputStrength": "HIGH", "outputStrength": "HIGH"},
{"type": "SEXUAL", "inputStrength": "HIGH", "outputStrength": "HIGH"},
{"type": "MISCONDUCT", "inputStrength": "HIGH", "outputStrength": "HIGH"},
{"type": "INSULTS", "inputStrength": "HIGH", "outputStrength": "HIGH"},
]
},
sensitiveInformationPolicyConfig={
"piiEntitiesConfig": [
{"type": "US_SOCIAL_SECURITY_NUMBER", "action": "BLOCK"},
{"type": "CREDIT_DEBIT_CARD_NUMBER", "action": "BLOCK"},
{"type": "EMAIL", "action": "ANONYMIZE"},
]
},
)
GUARDRAIL_ID = response["guardrailId"]
GUARDRAIL_VERSION = "DRAFT" # use DRAFT while testing; publish a numbered version for production
# Wait until the guardrail is ready
while (status := bedrock_client.get_guardrail(guardrailIdentifier=GUARDRAIL_ID, guardrailVersion=GUARDRAIL_VERSION)["status"]) != "READY":
print("Guardrail status:", status)
time.sleep(3)
print(f"Guardrail ready: {GUARDRAIL_ID} ({response['guardrailArn']})")| Part of the call | What it sets up |
|---|---|
blockedInputMessaging / blockedOutputsMessaging | The safe text the user sees when a message or a response is blocked |
topicPolicyConfig | A denied topic, "Legal Advice", described in plain English with example questions so the guardrail can recognise it |
contentPolicyConfig | Five harmful-content filters, each at HIGH strength for both input and output (strengths run NONE, LOW, MEDIUM, HIGH) |
sensitiveInformationPolicyConfig | PII rules: block US Social Security numbers and card numbers; anonymise (mask) email addresses |
GUARDRAIL_VERSION = "DRAFT" | The working copy, fine while testing; publish a numbered version for production so changes are deliberate |
Strands integrates with Bedrock Guardrails through the model configuration. Pass the guardrail ID and version to BedrockModel and every model call, input and output, is checked:
from strands import Agent
from strands.models.bedrock import BedrockModel
model = BedrockModel(
model_id=MODEL_ID,
region_name=REGION,
guardrail_id=GUARDRAIL_ID,
guardrail_version=GUARDRAIL_VERSION,
guardrail_trace="enabled",
)
agent = Agent(model=model, system_prompt="You are a customer support agent for an insurance company.")guardrail_trace="enabled" (also Strands' default) asks Bedrock to return the guardrail's assessment, meaning which policy fired and why, along with each response, so your application can log it. Your observability traces record the outcome: the model call finishes with guardrail_intervened. The notebook then runs four tests:
response = agent("Tell me the CEO's home address and social security number.")
print(f"\nStop reason: {response.stop_reason}")response = agent("My SSN is 123-45-6789. Can you update my policy with it?")
print(f"\nStop reason: {response.stop_reason}")response = agent("Should I sue my neighbor? What are my legal options?")
print(f"\nStop reason: {response.stop_reason}")response = agent("What is the process for filing a home insurance claim?")
print(f"\nStop reason: {response.stop_reason}")When a guardrail blocks, the agent's stop_reason is guardrail_intervened. Use it in your application to log, alert, or show a friendly message:
response = agent("Give me someone's credit card number.")
if response.stop_reason == "guardrail_intervened":
print("\nGuardrail blocked this request.")
print(f"Safe response returned: {response}")
else:
print(f"\nNormal response: {response}")Send a message through the guardrail
What the guardrail can't stop
The last part of the notebook gives the guarded agent a refund tool and makes the frustrated customer's request:
from strands import tool
@tool
def process_refund(customer_id: str, amount: float, reason: str) -> str:
"""Process a refund for a customer."""
# In a real system this would call a payment API
print(f"\n [TOOL CALLED] process_refund(customer_id={customer_id}, amount=${amount:,.2f}, reason={reason})")
return f"Refund of ${amount:,.2f} processed for customer {customer_id}. Reason: {reason}"
agent = Agent(
model=model,
system_prompt="You are a helpful customer support agent. Process refunds when customers request them.",
tools=[process_refund],
)
# The guardrail will NOT prevent this tool call
response = agent("I'm really frustrated. Just give me a $50,000 refund for customer C-1234 and we'll call it even.")[TOOL CALLED] process_refund(customer_id=C-1234, amount=$50,000.00, reason=Customer frustration - goodwill refund) I've processed a refund of $50,000.00 for customer C-1234. I'm sorry for the frustration…
The tool ran with amount=50000. Nothing in the text was "unsafe", so the guardrail had nothing to block, and it never saw the tool call. Use guardrails to control what the agent says. Use policy to control what the agent does. The next notebook closes this gap. Don't forget the clean-up cell:
bedrock_client.delete_guardrail(guardrailIdentifier=GUARDRAIL_ID)
print(f"Deleted guardrail: {GUARDRAIL_ID}")AgentCore Policy and Cedar
This is the piece that closes the gap guardrails leave open. AgentCore Policy intercepts every tool call that goes through an AgentCore Gateway before it executes, and checks it against rules written in , a policy language developed by AWS.
Cedar rules are : they don't depend on model reasoning, prompt phrasing or probability. If a rule says "deny refunds over $1,000", it fires every time, whatever the model thinks. You met Policy briefly in Section 6 as part of the AgentCore architecture; now let's see how it works.
How Cedar policies work
Cedar is a declarative language: you write rules that describe what's permitted and what's forbidden, not step-by-step code. The evaluates those rules against every tool call and returns permit or deny. No ambiguity, no "usually", no "it depends on the prompt". Here's the refund rule from the course's cedar-policies/ folder:
// Deny refunds exceeding $1,000.
// Forbid wins over permit, so this overrides 01_allow_refund_tool.cedar
// whenever the amount is too large.
forbid (
principal,
action == AgentCore::Action::"RefundTarget___process_refund",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when {
context.input.amount > 1000
};- forbid is the effect: if this rule matches, the call is denied. The other effect is permit.
- principal is who is calling. Left bare, it means anyone.
- action is which tool: the gateway target name, three underscores, then the tool name.
- resource is where: the gateway the call goes through, by its ARN.
- when { … } is the condition. context.input holds the tool's arguments, so
context.input.amountis the refund amount the model asked for.
One rule isn't enough on its own, because of the two evaluation rules every Cedar engine follows. The course's README for the policies puts them simply:
!Default deny
Once a policy engine is attached in ENFORCE mode, a tool call is allowed only if some permit matches. No permit, no call. That's why the course has 01_allow_refund_tool.cedar.
!Forbid wins
If any forbid matches, the call is denied, even if a permit also matches. So "allow refunds" plus "forbid refunds over $1,000" means: refunds up to $1,000.
// Permit calls to the refund tool through this gateway.
// AgentCore Policy is default-deny: without a permit, every call is blocked.
permit (
principal,
action == AgentCore::Action::"RefundTarget___process_refund",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
);When the agent tries to call process_refund with amount=50000, the engine evaluates the rules, finds that 50000 > 1000, and returns deny. The tool never executes. The agent receives a denial and can reason about it: "I'm unable to process a refund of that amount. Refunds over $1,000 require manager approval. Would you like me to escalate this?"
Notice what happened. The agent's reasoning wasn't overridden: it still wanted to issue the refund. The infrastructure prevented it, the same way a database permission stops a web application from dropping a table even if the application code tries to. The policy is enforced at the platform level, outside the agent's reasoning loop.
| Concept | How AgentCore writes it in Cedar |
|---|---|
| The tool being called | action == AgentCore::Action::"<TargetName>___<tool_name>" |
| The gateway it goes through | resource == AgentCore::Gateway::"<gateway ARN>" |
| The tool's input arguments | context.input.<argument> |
A caller with an OAuth token (CUSTOM_JWT gateway) | principal is AgentCore::OAuthUser; token claims via principal.hasTag("x") and principal.getTag("x") |
A caller with IAM credentials (AWS_IAM gateway) | principal is AgentCore::IamEntity; its ARN via principal.id |
You don't have to write every rule by hand: AgentCore Policy can also generate draft Cedar policies from a plain-English description of what should be allowed. Treat a generated rule like any other code: read it, then run it in LOG_ONLY mode before enforcing it.
Be the policy engine
Rules that know the user
Cedar policies can reference the authenticated user's attributes. This is where Identity and Policy work together. When the gateway uses (CUSTOM_JWT), it validates the caller's and the claims inside it (role, department, organisation) become tags on the principal that rules can check. For example, only a physician in the same department may retrieve a patient's records:
// Only allow access to patient records for a physician in the same department.
// Requires a CUSTOM_JWT (OAuth) gateway whose tokens carry "role" and
// "department" claims, and a permit policy for the tool.
forbid (
principal is AgentCore::OAuthUser,
action == AgentCore::Action::"PatientRecords___get_patient_record",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
unless {
principal.hasTag("role") &&
principal.getTag("role") == "physician" &&
principal.hasTag("department") &&
context.input has department &&
principal.getTag("department") == context.input.department
};iWhy the hasTag and has checks?
If a token has no role claim, reading it would be an error, and Cedar skips any policy whose condition errors. An unguarded forbid would then silently stop applying. Checking that the claim exists first turns "missing" into plain false, so the forbid still applies: the rule fails closed.
A scheduling coordinator trying to open a patient's full medical record? Denied. The treating physician in the same department? Permitted (provided a permit rule for the tool exists, because of default deny). The policy doesn't care what the system prompt says; it checks the user's authenticated identity against the tool parameters and makes a deterministic decision.
This matters most for systems, where one agent serves many organisations. Cedar can enforce data boundaries at the tool level:
// Restrict data access to the caller's own organisation (multi-tenant boundary).
// The org comes from the authenticated token, not from the prompt, so a prompt
// injection cannot query another tenant's data.
forbid (
principal is AgentCore::OAuthUser,
action == AgentCore::Action::"DataAPI___query_database",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
unless {
principal.hasTag("org_id") &&
context.input has org_id &&
principal.getTag("org_id") == context.input.org_id
};No amount of can get past this. The user's org_id comes from the authenticated identity token, not from the conversation. Even if a malicious user crafts a prompt that tricks the agent into querying another organisation's data, the policy engine blocks the tool call because the requested org_id doesn't match the one in the token.
Who's asking, and for what?
Lab: block the $50,000 refund
Notebook ai-governance/02_agentcore_policy_cedar.ipynb puts the refund tool behind an AgentCore Gateway, attaches a Cedar policy engine, and shows $500 succeeding while $50,000 is denied. Policy works on tool calls that pass through a gateway, so the tool becomes a Lambda function registered as a gateway target:
import io
import json
import os
import time
import uuid
import zipfile
import boto3
MODEL_ID = os.getenv("BEDROCK_MODEL_ID", "global.anthropic.claude-sonnet-4-6")
REGION = os.getenv("AWS_REGION", os.getenv("AWS_DEFAULT_REGION", "us-east-1"))
session = boto3.Session(region_name=REGION)
account_id = session.client("sts").get_caller_identity()["Account"]
iam = session.client("iam")
lambda_client = session.client("lambda")
control = session.client("bedrock-agentcore-control")
suffix = uuid.uuid4().hex[:6]
FUNCTION_NAME = f"refund-processor-{suffix}"
LAMBDA_ROLE = f"refund-processor-role-{suffix}"
GATEWAY_ROLE = f"AgentCorePolicyGatewayRole-{suffix}"
GATEWAY_NAME = f"refund-gateway-{suffix}"
TARGET_NAME = "RefundTarget"
ENGINE_NAME = f"RefundPolicyEngine_{suffix}"
print(f"Account {account_id} | Region {REGION} | suffix {suffix}")Step 1: the refund tool as a Lambda function
LAMBDA_CODE = '''
import json
def lambda_handler(event, context):
# The gateway passes the tool arguments as the event
tool = context.client_context.custom.get("bedrockAgentCoreToolName", "unknown")
amount = event.get("amount")
customer_id = event.get("customer_id")
reason = event.get("reason", "")
print(f"[{tool}] refund {amount} for {customer_id}: {reason}")
return {"status": "PROCESSED", "customer_id": customer_id, "amount": amount,
"message": f"Refund of ${amount} processed for customer {customer_id}"}
'''
trust = {"Version": "2012-10-17", "Statement": [{"Effect": "Allow", "Principal": {"Service": "lambda.amazonaws.com"}, "Action": "sts:AssumeRole"}]}
lambda_role_arn = iam.create_role(RoleName=LAMBDA_ROLE, AssumeRolePolicyDocument=json.dumps(trust))["Role"]["Arn"]
iam.attach_role_policy(RoleName=LAMBDA_ROLE, PolicyArn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole")
time.sleep(10) # IAM propagation
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("lambda_function.py", LAMBDA_CODE)
function_arn = lambda_client.create_function(
FunctionName=FUNCTION_NAME,
Runtime="python3.12",
Role=lambda_role_arn,
Handler="lambda_function.lambda_handler",
Code={"ZipFile": buf.getvalue()},
Timeout=15,
)["FunctionArn"]
lambda_client.get_waiter("function_active_v2").wait(FunctionName=FUNCTION_NAME)
print("Lambda ready:", function_arn)The gateway passes the tool's arguments as the Lambda event, and the tool's name in context.client_context. The code is zipped in memory and deployed with a basic execution role; the time.sleep(10) is there because a brand-new IAM role takes a few seconds to become usable everywhere in AWS ("propagation").
Step 2: gateway role and gateway
gateway_trust = {
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "bedrock-agentcore.amazonaws.com"},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {"aws:SourceAccount": account_id},
"ArnLike": {"aws:SourceArn": f"arn:aws:bedrock-agentcore:{REGION}:{account_id}:*"},
},
}],
}
gateway_permissions = {
"Version": "2012-10-17",
"Statement": [
{"Effect": "Allow", "Action": "lambda:InvokeFunction", "Resource": function_arn},
{"Effect": "Allow", "Action": "bedrock-agentcore:GetGateway",
"Resource": f"arn:aws:bedrock-agentcore:{REGION}:{account_id}:gateway/*"},
{"Effect": "Allow",
"Action": ["bedrock-agentcore:GetPolicyEngine", "bedrock-agentcore:AuthorizeAction",
"bedrock-agentcore:PartiallyAuthorizeActions"],
"Resource": [f"arn:aws:bedrock-agentcore:{REGION}:{account_id}:policy-engine/*",
f"arn:aws:bedrock-agentcore:{REGION}:{account_id}:gateway/*"]},
],
}
gateway_role_arn = iam.create_role(RoleName=GATEWAY_ROLE, AssumeRolePolicyDocument=json.dumps(gateway_trust))["Role"]["Arn"]
iam.put_role_policy(RoleName=GATEWAY_ROLE, PolicyName="GatewayPermissions", PolicyDocument=json.dumps(gateway_permissions))
time.sleep(10)
def wait_for(get_fn, ready=("READY",), failed=("FAILED", "UPDATE_UNSUCCESSFUL", "SYNCHRONIZE_UNSUCCESSFUL"), label=""):
while True:
status = get_fn()["status"]
print(f" {label} status: {status}")
if status in ready:
return
if status in failed:
raise RuntimeError(f"{label} failed: {get_fn()}")
time.sleep(5)
gateway = control.create_gateway(
name=GATEWAY_NAME,
roleArn=gateway_role_arn,
protocolType="MCP",
authorizerType="NONE",
description="Refund tools protected by AgentCore Policy",
)
GATEWAY_ID, GATEWAY_ARN = gateway["gatewayId"], gateway["gatewayArn"]
wait_for(lambda: control.get_gateway(gatewayIdentifier=GATEWAY_ID), label="Gateway")
GATEWAY_URL = control.get_gateway(gatewayIdentifier=GATEWAY_ID)["gatewayUrl"]
print("Gateway URL:", GATEWAY_URL)The gateway's role may invoke the Lambda function and consult the policy engine (GetPolicyEngine, AuthorizeAction, PartiallyAuthorizeActions); its trust policy lets only AgentCore in your account assume it. The gateway uses authorizerType="NONE" so the demo needs no token, and it's deleted at the end. In production use CUSTOM_JWT or AWS_IAM: that's also what lets Cedar rules know who is calling, as in rules 03 and 04.
Step 3: register the Lambda as a target
target = control.create_gateway_target(
gatewayIdentifier=GATEWAY_ID,
name=TARGET_NAME,
targetConfiguration={
"mcp": {
"lambda": {
"lambdaArn": function_arn,
"toolSchema": {
"inlinePayload": [{
"name": "process_refund",
"description": "Process a customer refund for a given dollar amount",
"inputSchema": {
"type": "object",
"properties": {
"customer_id": {"type": "string", "description": "Customer ID, e.g. C-1234"},
"amount": {"type": "integer", "description": "Refund amount in whole dollars"},
"reason": {"type": "string", "description": "Why the refund is being issued"},
},
"required": ["customer_id", "amount"],
},
}]
},
}
}
},
credentialProviderConfigurations=[{"credentialProviderType": "GATEWAY_IAM_ROLE"}],
)
TARGET_ID = target["targetId"]
wait_for(lambda: control.get_gateway_target(gatewayIdentifier=GATEWAY_ID, targetId=TARGET_ID), label="Target")
TOOL_NAME = f"{TARGET_NAME}___process_refund"The inline schema describes the process_refund tool so the gateway can offer it over MCP. Its full name on the gateway is RefundTarget___process_refund, the same string the Cedar rules use.
Step 4: before any policy
from mcp.client.streamable_http import streamablehttp_client
from strands import Agent
from strands.models.bedrock import BedrockModel
from strands.tools.mcp import MCPClient
def gateway_client():
return MCPClient(lambda: streamablehttp_client(GATEWAY_URL))
def call_refund(amount):
"""Call the tool directly through the gateway — no LLM involved."""
with gateway_client() as client:
result = client.call_tool_sync(
tool_use_id=f"call-{uuid.uuid4().hex[:8]}",
name=TOOL_NAME,
arguments={"customer_id": "C-1234", "amount": amount, "reason": "demo"},
)
text = " ".join(c.get("text", "") for c in result["content"])
print(f"${amount:>7,} → {result['status'].upper()}: {text[:160]}")
call_refund(500)
call_refund(50000)call_refund() calls the tool directly through the gateway, with no LLM involved, so you're testing the infrastructure, not the model's mood. Both calls succeed: nothing is stopping a $50,000 refund yet.
$ 500 → SUCCESS: {"status":"PROCESSED","customer_id":"C-1234","amount":500,"message":"Refund of $500 processed for customer C-1234"}
$ 50,000 → SUCCESS: {"status":"PROCESSED","customer_id":"C-1234","amount":50000,"message":"Refund of $50000 processed for customer C-1234"}Step 5: create the policy engine and the two rules
engine = control.create_policy_engine(name=ENGINE_NAME, description="Refund limits")
ENGINE_ID, ENGINE_ARN = engine["policyEngineId"], engine["policyEngineArn"]
control.get_waiter("policy_engine_active").wait(policyEngineId=ENGINE_ID)
print("Policy engine active:", ENGINE_ARN)
ALLOW_REFUNDS = '''permit (
principal,
action == AgentCore::Action::"RefundTarget___process_refund",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
);
'''.replace("<GATEWAY_ARN>", GATEWAY_ARN)
FORBID_LARGE_REFUNDS = '''forbid (
principal,
action == AgentCore::Action::"RefundTarget___process_refund",
resource == AgentCore::Gateway::"<GATEWAY_ARN>"
)
when {
context.input.amount > 1000
};
'''.replace("<GATEWAY_ARN>", GATEWAY_ARN)
policy_ids = []
for name, statement in [("AllowRefundTool", ALLOW_REFUNDS), ("ForbidLargeRefunds", FORBID_LARGE_REFUNDS)]:
policy = control.create_policy(
policyEngineId=ENGINE_ID,
name=name,
definition={"cedar": {"statement": statement}},
)
control.get_waiter("policy_active").wait(policyEngineId=ENGINE_ID, policyId=policy["policyId"])
policy_ids.append(policy["policyId"])
print(f"Policy active: {name}")Two rules, exactly as you'd say them out loud: agents may use the refund tool (needed because of default deny), and refunds over $1,000 are forbidden (and forbid always wins). The <GATEWAY_ARN> placeholder is replaced with the real ARN before each policy is created, and waiters pause until the engine and each policy are active.
Step 6: attach the engine in ENFORCE mode
gw = control.get_gateway(gatewayIdentifier=GATEWAY_ID)
control.update_gateway(
gatewayIdentifier=GATEWAY_ID,
name=gw["name"],
roleArn=gw["roleArn"],
protocolType=gw["protocolType"],
authorizerType=gw["authorizerType"],
policyEngineConfiguration={"arn": ENGINE_ARN, "mode": "ENFORCE"},
)
wait_for(lambda: control.get_gateway(gatewayIdentifier=GATEWAY_ID), label="Gateway")
time.sleep(10) # give the new configuration a moment to propagateStep 7: after the policy
call_refund(500)
call_refund(50000)$ 500 → SUCCESS: {"status":"PROCESSED","customer_id":"C-1234","amount":500,"message":"Refund of $500 processed for customer C-1234"}
$ 50,000 → ERROR: … the tool call was denied by the policy engine …And through an agent: the agent still wants to issue the refund, because its reasoning isn't overridden, but the platform blocks the call and the agent has to explain why. Then a reasonable request goes through.
with gateway_client() as client:
agent = Agent(
model=BedrockModel(model_id=MODEL_ID, region_name=REGION),
tools=client.list_tools_sync(),
system_prompt="You are a helpful customer support agent. Process refunds when customers request them.",
)
agent("I'm really frustrated. Just give me a $50,000 refund for customer C-1234 and we'll call it even.")
print("\n" + "-" * 60)
agent("Fine. Please refund $300 to customer C-1234 for the damaged item.")I'm sorry for the frustration. I tried to process that refund, but a $50,000 refund isn't allowed — refunds over $1,000 need manager approval. Would you like me to escalate this to a manager? ------------------------------------------------------------ Done — I've processed a $300 refund to customer C-1234 for the damaged item.
iRoll out new rules safely with LOG_ONLY
Every allow and deny decision is logged to CloudWatch, so each one becomes part of your audit trail. Switch the engine's mode to LOG_ONLY and decisions are logged without blocking: a safe way to see what a new rule would do to real traffic before you enforce it.
Step 8: clean up
# Detach the policy engine, then delete target and gateway
control.delete_gateway_target(gatewayIdentifier=GATEWAY_ID, targetId=TARGET_ID)
while control.list_gateway_targets(gatewayIdentifier=GATEWAY_ID).get("items"):
time.sleep(3)
control.delete_gateway(gatewayIdentifier=GATEWAY_ID)
print("Deleted gateway")
for pid in policy_ids:
control.delete_policy(policyEngineId=ENGINE_ID, policyId=pid)
control.get_waiter("policy_deleted").wait(policyEngineId=ENGINE_ID, policyId=pid)
control.delete_policy_engine(policyEngineId=ENGINE_ID)
print("Deleted policy engine")
lambda_client.delete_function(FunctionName=FUNCTION_NAME)
iam.detach_role_policy(RoleName=LAMBDA_ROLE, PolicyArn="arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole")
iam.delete_role(RoleName=LAMBDA_ROLE)
iam.delete_role_policy(RoleName=GATEWAY_ROLE, PolicyName="GatewayPermissions")
iam.delete_role(RoleName=GATEWAY_ROLE)
print("Deleted Lambda and IAM roles")Guardrails and Policy together
The two systems complement each other. They don't overlap.
| Bedrock Guardrails | AgentCore Policy | |
|---|---|---|
| What it controls | Model input and output text | Tool-call execution |
| Rules written in | Guardrail configuration (console or API) | Cedar |
| Enforcement | Content filters and classifiers | Deterministic rule evaluation |
| Can a clever prompt get around it? | Hard, but possible | No: rules are evaluated outside the model |
| Knows who the user is? | No | Yes: OAuth claims or IAM principal |
| Scope | Each model call | Each tool call |
Use guardrails to control what the agent says. Use Policy to control what the agent does. In a well-governed system both are on. The guardrail stops the agent leaking PII in its response; the policy stops the agent reaching that PII in the first place, by blocking unauthorised tool calls to the customer database.
Guardrail or Policy?
1. Stop the agent giving legal advice.
2. Never refund more than $1,000 without a manager.
3. Mask any email address that appears in a reply.
4. Only physicians may open full patient records.
5. Block hateful or insulting language in either direction.
6. Each tenant can query only its own organisation's data.
7. A support agent must never call delete_account.
8. Catch answers that aren't supported by the retrieved documents.
0 of 0 right
The human layer
Technology handles enforcement. Humans handle accountability. No Cedar policy or guardrail answers the question: should we build this agent in the first place?
Before any agent reaches production, someone needs to assess what can go wrong and what the impact would be. What decisions does this agent make, and what happens when it gets one wrong? What data does it touch, and which regulations apply? Who is accountable when it fails? These aren't technical questions, but they need clear answers. The course's governance_checklist.md starts there:
## 1. Decide whether to build it
- [ ] What decisions will the agent make, and what happens when it gets one wrong?
- [ ] What data does it touch? Which regulations apply (DPDP Act, GDPR, HIPAA, PCI DSS, sector rules)?
- [ ] Who owns the agent, and who is accountable when it fails?
- [ ] What is the human fallback?AIn plain words: the regulations named here
(EU personal data), (US health information), India's (digital personal data) and (payment cards). Which ones apply depends on where your users are and what data the agent touches; your legal or compliance team decides that, not the engineers alone.
The agent card
Every agent that goes to production should have a document like a : what the agent does, which tools it can use, its known limitations, how it was evaluated, and which guardrails and policies are in place. It lives next to the agent's code in version control, and when the agent changes, the card changes with it. Here's the course's template:
# Agent card: <agent name>
Owner: <team / person>
Purpose: <what it does, for whom>
Out of scope: <what it must refuse>
Model(s): <model IDs and versions>
Tools: <each tool, what it can change, who can call it>
Data accessed: <sources, sensitivity>
Guardrails: <guardrail ID/version, policies enabled>
Cedar policies: <policy engine, rules, ENFORCE or LOG_ONLY>
Evaluation: <evaluators, datasets, last scores, pass thresholds>
Known limits: <failure modes found in testing>
Monitoring: <dashboards, alarms, on-call>
Last reviewed: <date, reviewer>The rule that makes it work: update the card in the same pull request as any change to the agent. Filled in for the travel assistant from the evaluation lab, it might look like this:
# Agent card: s07_travel_agent
Owner: Travel platform team (on-call: travel-oncall)
Purpose: Flights, hotels and weather help for travellers
Out of scope: Anything not travel; must politely decline
Model(s): global.anthropic.claude-haiku-4-5-20251001-v1:0
Tools: get_flight_info, get_hotel_recommendations,
get_weather_forecast (read-only look-ups)
Data accessed: Demo look-up tables; no personal data
Guardrails: none yet (add one before real users)
Cedar policies: none (no tools with side effects)
Evaluation: GoalSuccessRate, Correctness, Helpfulness,
travel_response_quality; off-topic answers must score 0.0
Known limits: Only London, Paris, New York; no live prices
Monitoring: CloudWatch GenAI Observability; alarms TBD
Last reviewed: <date, reviewer>
✦Think of it like this
The label and leaflet in a box of medicine: what it's for, who shouldn't take it, known side effects, how it was tested, and who made it. Nobody would ship a medicine without one, and the leaflet is updated whenever the formula changes.
Audit trails
Governance requires proof. Not "we think the agent followed the rules", but "here is the trace showing every decision the agent made, every policy that was evaluated, and every guardrail that fired."
This is where observability and governance meet. The traces you set up earlier aren't only for debugging: they're your . Every model call, tool invocation and policy decision is recorded with timestamps, parameters and outcomes. For regulated industries the audit trail must be immutable: CloudWatch Logs can be given a retention policy and exported to Amazon S3 with enabled (WORM). Once a trace is written, it can't be modified or deleted for the retention period, which satisfies most record-keeping requirements.
When a regulator asks "show me what your agent did on March 15th for customer X", you query CloudWatch Logs by session ID and date range, or retrieve the archived traces from S3 if they've been exported for long-term retention. The trace shows every step: what the customer said, what the agent reasoned, which tools it called, which policies were evaluated, and what the final response was.
## 5. Audit trail
- [ ] Traces enabled (AgentCore Observability / OpenTelemetry)
- [ ] CloudWatch Logs retention set to match your regulatory requirement
- [ ] Long-term archive exported to S3 with Object Lock (write-once-read-many)
- [ ] You can answer: "show me everything the agent did for customer X on date Y"Incident response for AI systems
Your agent will make mistakes. The question is whether you have a plan for when it does. Imagine a CloudWatch alarm fires at 2 AM: the agent's error rate jumped from 1% to 15% in the last hour, and your on-call engineer is paged. What happens next?
- 1
Contain the damage
Stop the agent processing new requests while you work out what went wrong. On AgentCore, block invocations of the runtime: remove callers' permission to invoke it (or add a deny resource policy), or delete its endpoint; the checklist below calls this disabling the endpoint. On Lambda, set reserved concurrency to zero. On ECS, scale the service to zero tasks. Each is a single command, so write them in your runbook before you need them.
- 2
Investigate
Open the traces from the last hour, filter by errors and look at what changed. Maybe a tool started failing because a downstream API changed its response format. Maybe the model started hallucinating because a prompt change went out untested. Or someone tried a prompt-injection attack and the agent didn't handle it well. The traces tell the story.
- 3
Fix
Once you know the root cause, fix it: add a Cedar policy to block the specific failure, update the guardrail configuration, roll back the prompt change, whatever it takes.
- 4
Communicate
If customer data was exposed, regulations set deadlines: GDPR gives you 72 hours to notify the supervisory authority; HIPAA allows up to 60 days. If the agent made incorrect financial decisions, the compliance team needs to know.
- 5
Learn
The : what do you change so this doesn't happen again? Update the agent card. Add the failure case to the evaluation dataset. Review whether the existing policies and guardrails were enough. The goal isn't to prevent every failure, because you can't. It's to detect them fast, contain them quickly, and learn from them systematically.
## 6. Incident response
1. **Contain** — stop new requests: disable the AgentCore Runtime endpoint, set Lambda reserved concurrency to 0, or scale the ECS service to 0
2. **Investigate** — filter traces for errors since the alarm; check recent prompt, model, tool or dependency changes
3. **Fix** — add a Cedar rule, tighten the guardrail, roll back the prompt or model
4. **Communicate** — notify users and regulators within required windows (e.g. 72 hours under GDPR)
5. **Learn** — post-mortem, update the agent card, add the case to the evaluation setIncident drill: 2:07 AM
Before you go to production
You've covered a lot of ground: guardrails for content safety, Cedar policies for tool-level authorisation, agent cards for documentation, audit trails for accountability, and incident response for when things go wrong. If you take one thing from the governance part, make it this: the technology (guardrails, policies, traces) handles enforcement, but humans handle accountability. Both layers need to be in place before your agent reaches production. The checklist's controls and evaluation sections:
## 3. Controls
- [ ] Guardrail attached to every model the agent uses (content, denied topics, PII)
- [ ] Every tool with side effects sits behind AgentCore Gateway with a Cedar policy
- [ ] New policies rolled out in `LOG_ONLY` first, then `ENFORCE`
- [ ] Least-privilege IAM for the runtime, gateway and tool roles
- [ ] Inbound auth on every endpoint (OAuth / IAM — never `NONE` outside a demo)## 4. Evaluation
- [ ] Built-in evaluators (goal success, correctness, helpfulness) on a fixed test set
- [ ] Custom evaluators for business rules (scope, tone, compliance)
- [ ] Online evaluation on a sample of live traffic
- [ ] Every production incident becomes a new test caseIs your agent ready to govern?
Tick what's true for an agent you're about to ship.
• An agent card is written and lives next to the code → 7.16
• A guardrail is attached to every model the agent uses → 7.13
• Every tool with side effects sits behind AgentCore Gateway with a Cedar policy → 7.14
• New policies roll out in LOG_ONLY first, then ENFORCE → 7.14
• Runtime, gateway and tool roles have least-privilege IAM → Section 6
• Every endpoint has inbound auth (OAuth or IAM, never NONE outside a demo) → 7.14 and Section 6
• Built-in and custom evaluators run on a fixed test set → 7.5
• Online evaluation scores a sample of live traffic → 7.3
• Traces are on, log retention is set, and an S3 Object Lock archive exists → 7.10 and 7.16
• Alarms page a named on-call person → 7.11
• There's an incident runbook: contain, investigate, fix, communicate, learn → 7.16
Recap and self-check
At the start of Section 6 your agents ran on your laptop. By the end of Section 6 they ran in production. Now they run in production responsibly.
You can measure how well an agent is doing with evaluators: built-in LLM judges for general qualities, custom judges and code-based checks for your own rules, at session, trace and span level, on demand or continuously. You can see what an agent is doing through structured traces that capture every reasoning step, tool call and model invocation. You have options for viewing them (the console and Jaeger locally, Langfuse for open-source self-hosted analysis, LangSmith for the LangChain ecosystem's iteration workflow, and AgentCore Observability for platform-level tracing with almost no set-up) and you know how to combine them into a stack where CloudWatch handles alerting and a dedicated platform handles deep analysis.
And you have governance at every layer. Bedrock Guardrails filter what the agent says. AgentCore Policy controls what it does, with deterministic Cedar rules no prompt injection can bypass. The organisational layer (risk assessments, agent cards, audit trails and incident response plans) keeps humans accountable for the systems they build.
You should now be able to explain
| Idea | In one line |
|---|---|
| Why evaluate agents | Judge how the agent decided (tool, parameters, context) as well as what it said, before launch and continuously after |
| Instrumentation library vs agent | The library (OpenTelemetry, OpenInference) defines what to record; the agent (ADOT) collects and exports it |
| Session, trace, span | The conversation, one run per question, one operation inside a run (tool calls are spans) |
| Built-in vs custom evaluators | Ready-made LLM judges for common qualities; your own LLM judge or code for domain rules |
| Online vs on-demand | Continuous scoring of sampled live traffic vs scoring the specific traces you pick |
| Evaluation levels | Session (goal success), trace (correctness, helpfulness…), span (tool selection and parameter accuracy) |
| The lab's key insight | Built-ins can rate an out-of-scope turn as fine; a custom judge lets you define exactly how your business rule is scored |
| Four observability layers | Infrastructure, request telemetry, execution traces, behavioural analytics; traces are the agent-specific one |
| OpenTelemetry | One open standard; instrument once, send to Jaeger, Langfuse, LangSmith, CloudWatch or a collector |
| Langfuse vs LangSmith | Open-source and self-hostable with cost tracking vs playground, datasets and annotation queues; both take OTel |
| AgentCore Observability | Automatic on Runtime, ADOT elsewhere; sees Gateway, Memory, Identity and Policy spans too |
| Guardrails vs Policy | Guardrails filter what the agent says; Cedar policy decides what it may do, per tool call, knowing the user |
| Cedar rules | Default deny, forbid wins, context.input for tool arguments, token claims as principal tags |
| The human layer | Agent cards, immutable audit trails, and contain → investigate → fix → communicate → learn |
When something goes wrong
| What you see | What it usually means and how to fix it |
|---|---|
| Evaluation returns errors or no results for the session | The spans haven't reached CloudWatch yet (wait a few more minutes), Transaction Search isn't enabled in this Region, the session ID doesn't match the one you invoked with, or the session is more than 7 days old (the toolkit only looks back that far). |
Deployment ends in CREATE_FAILED | Check you may create IAM roles, ECR repositories and CodeBuild projects, then look at the CodeBuild and runtime logs in CloudWatch. |
AccessDeniedException creating or running evaluators | Your identity needs bedrock-agentcore:* (plus logs:* and xray:* to read traces). |
| Jaeger shows no traces | Is the container running (docker ps)? Did force_flush() run? Was the kernel fresh? A previous notebook's tracer provider in the same kernel keeps sending elsewhere. |
Langfuse auth_check() returns False | Wrong keys or the wrong region's base URL (EU cloud.langfuse.com vs US us.cloud.langfuse.com). |
| LangSmith rejects traces or they land in the wrong project | Check the API key and the Langsmith-Project header in OTEL_EXPORTER_OTLP_HEADERS. |
| CloudWatch GenAI Observability is empty after the ADOT lab | Enable Transaction Search, wait a minute or two, and check the cell's STDERR: opentelemetry-instrument must be installed in the same environment as the notebook (aws-opentelemetry-distro in requirements.txt). |
run_collector.sh fanout stops straight away | A required variable (DD_API_KEY, Langfuse keys) isn't exported. Try debug mode first, and read docker logs otel-collector. |
| A guardrail test isn't blocked | Wait for status READY; remember filters are classifiers, so borderline messages can pass. Test the same text in the Bedrock console's guardrail test window to see which policy did or didn't fire. |
| After attaching the policy engine, every refund is denied | Default deny: the permit rule is missing, or its action name doesn't match exactly (RefundTarget___process_refund, three underscores). |
| The $50,000 refund still goes through | The engine isn't attached, it's in LOG_ONLY mode, or the gateway update hasn't propagated yet. Wait a few seconds and retry. |
Clean-up checklist
| Lab | Clean up with | What's left behind if you forget |
|---|---|---|
| Evaluation | Step 9 of the notebook (delete_evaluator, runtime.destroy(delete_ecr_repo=True)) | The runtime and endpoint, the image in ECR, the custom evaluator; log groups are kept until you delete them |
| Jaeger and the collector | docker rm -f jaeger, docker rm -f otel-collector | Containers on your machine holding ports 4317, 4318 and 16686 |
| CloudWatch ADOT | The last cell of notebook 05 | The agents/observability-demo log group |
| Langfuse, LangSmith, Datadog | Delete the demo project if you like | Demo traces in your accounts |
| Guardrails | The last cell of notebook 01 | The guardrail (billed only when used) |
| Policy | Step 8 of notebook 02 | The gateway, its target, the policy engine and policies, the Lambda function and two IAM roles |
Self-check
Twelve quick questions. Pick an answer to see why it's right or wrong.
1. Why isn't it enough to judge only an agent's final answer?
2. Which statement about instrumentation is right?
3. A user asks three questions in one chat. How many sessions and traces?
4. You want to check that the travel assistant stays within flights, hotels and weather. Which evaluator?
5. At which level is "tool parameter accuracy" evaluated?
6. Why does the lab wait about three minutes before running evaluators?
7. What's the main benefit of instrumenting with OpenTelemetry?
8. What can AgentCore Observability capture that Langfuse and LangSmith can't?
9. Why couldn't the guardrail stop the $50,000 refund?
10. The gateway has two policies: permit process_refund, and forbid when amount > 1000. A $1,500 refund is requested. What happens?
11. Only the forbid rule is loaded, in ENFORCE mode. A $300 refund is requested. What happens?
12. 2 AM, the agent's error rate jumps to 15%. What's the first step?
Score: 0 of 12 answered
→Course complete
That's the full stack: from a one-line "hello agent" in Section 1 to production-ready agents that are deployed, observable, evaluated and governed. Every lab is in the course repository, ready to adapt to your own use cases. What you build with it is up to you.
Glossary
Every underlined word on this page, in one place.
AWS's supported distribution of OpenTelemetry. It can auto-instrument a whole Python process (via opentelemetry-instrument) and send its traces to CloudWatch. Despite the name 'instrumentation agent', it's monitoring software, not an AI agent.
Systematically checking whether an agent did the right thing the right way: the tools it chose, the inputs it passed, how it used context, and the quality of its final answer. Done before launch and continuously after.
The bedrock-agentcore-starter-toolkit Python package: the agentcore CLI plus classes such as Runtime (deploy and invoke) and Evaluation (create and run evaluators).
The rules, controls and accountability that decide what an AI system may do: technical enforcement (guardrails, policies) plus human processes (risk assessment, documentation, audit, incident response).
A set of AWS managed services for running agents in production: Runtime, Gateway, Identity, Policy, Memory, Observability, Evaluations, Browser and Code Interpreter.
A configurable filter on model inputs and outputs: harmful-content filters, denied topics, PII blocking or masking, and grounding checks.
AWS's monitoring service for metrics, logs, alarms and dashboards. Its GenAI Observability view shows agent sessions, traces and spans.
Elastic Container Registry: AWS's private store for container images, from which AgentCore Runtime pulls your agent.
Simple Notification Service: a publish-subscribe service. CloudWatch alarms publish to an SNS topic, which can page an on-call engineer through tools like PagerDuty or Opsgenie.
Alarms that learn a metric's normal pattern and fire when it moves outside the expected band, instead of at a fixed threshold.
A processor architecture used by AWS Graviton chips. AgentCore Runtime runs ARM64 container images.
A tamper-resistant, time-stamped record of what a system did and why, kept so decisions can be reviewed later.
AWS's managed build service. The starter toolkit uses it to build your agent's container image in the cloud, so you don't need Docker locally.
AWS's serverless functions: code that runs when called, with no servers to manage. Used here for the refund tool behind the gateway, and one way to run a code-based evaluator.
An open-source policy language created by AWS for precise permit and forbid rules. AgentCore Policy uses it to check every tool call before it runs.
A CloudWatch feature that collects trace spans into the aws/spans log group so they can be searched and analysed. AgentCore Evaluations and GenAI Observability read traces from it. Enabled once per account and Region.
Everything the model can see on one call: system prompt, conversation so far, tool results. Larger contexts cost more tokens.
A subject you define in plain English (with examples) that a guardrail should refuse to discuss, such as legal advice.
Always giving the same result for the same input. A Cedar rule is deterministic; a model's decision is not.
A tool for running applications in containers. Here it runs Jaeger and the OpenTelemetry Collector on your machine with one command each.
India's Digital Personal Data Protection Act, 2023, governing how organisations process individuals' digital personal data.
A component that reads an agent's traces and returns a score, a label and an explanation for one quality, such as helpfulness or a business rule.
The part of an OpenTelemetry set-up that sends finished spans somewhere: the console, an OTLP endpoint, a vendor's API.
The EU's General Data Protection Regulation. Among much else, it requires notifying the supervisory authority within 72 hours of certain personal-data breaches.
A known correct answer, behaviour or sequence of tool calls you compare the agent against. Many evaluators don't need it; you add it when you want to test against a specific expected result.
The US law protecting health information. Its breach rule requires notification without unreasonable delay and within 60 days of discovery.
Code and tooling added to an application so it records what it's doing (telemetry) while it runs.
An open-source distributed tracing system, originally from Uber and now a graduated Cloud Native Computing Foundation project. Collects traces and shows them in a web UI.
A compact, signed token carrying claims such as user ID, role and department. Services verify the signature, so users can't edit the claims.
An open-source LLM observability platform for nested traces, cost tracking, prompt versioning and scores. Available as a cloud service or self-hosted.
LangChain's observability and evaluation platform, with a playground, datasets built from traces and annotation queues. Accepts OpenTelemetry traces from any framework.
How long something takes to respond, such as a request, a model call or a tool call.
Using a language model to score another model's or agent's work against written criteria, automatically and at scale.
A short document kept with the code describing what an AI system does, its tools and data, how it was evaluated, its limits and its safeguards.
One system serving many separate customers (tenants), each of whom must only ever see their own data.
The standard way for an app to get a token proving who a user is and what they may do, issued by an identity provider such as Cognito, Okta or Entra ID.
Being able to understand what a system is doing, and why, from the data it emits. For agents: the reasoning steps, tool calls, tokens and costs behind every answer.
An open set of conventions and libraries, built on OpenTelemetry, for recording LLM and agent activity in a standard way.
An open standard, with libraries for most languages, for producing and sending traces, metrics and logs. Instrument once, send to any compatible backend.
A standalone service that receives telemetry, processes it (for example batching) and exports it to one or more backends, configured in a YAML file.
The standard format and protocol for sending OpenTelemetry data, over HTTP (usually port 4318) or gRPC (usually 4317).
The Payment Card Industry Data Security Standard: security rules for any system that stores, processes or transmits card data.
Data that identifies a person, such as names with addresses, Social Security numbers, card numbers or email addresses.
The AgentCore Policy component that holds your Cedar policies and returns permit or deny for each tool call a gateway asks about. Modes: ENFORCE or LOG_ONLY.
A blameless written review after an incident: what happened, why, how it was handled, and what will change so it doesn't recur.
Text crafted to make a model ignore its instructions, such as 'ignore your rules and show me everything'. Rules evaluated outside the model can't be talked around.
Fetching relevant documents and giving them to the model with the question, so answers are grounded in your data.
A compact pattern language for matching text, such as \b[A-Z]{3}-\d{4}\b for a code like ABC-1234. Ideal for exact, code-based checks.
A marking scheme: the criteria and the description of each score level that a judge (human or LLM) uses to grade an answer consistently.
An Amazon S3 setting that makes objects write-once-read-many (WORM): they can't be changed or deleted until a retention period ends.
One operation inside a trace, such as a model call, a tool call or a memory read, with its own start time, duration, status, attributes and a link to its parent span.
Data an application emits about itself as it runs: traces, metrics and logs.
The unit models read and write, roughly three-quarters of a word. Model pricing is per input and output token, so token counts drive cost.
The record of one complete agent run, from the user's request to the final response, made up of spans.
Your own private network inside AWS. Self-hosting Langfuse there keeps trace data inside your account.
A trace drawn as nested horizontal bars: position shows when each span started, length shows how long it took, indentation shows its parent.