If you search "learn AWS," you will find courses that cover 50+ services across 40+ hours of video. You finish the course knowing what Route 53 is, what Glacier does, and why you might use Lightsail, but you still cannot build an AI product because you have spent most of your time on services you will not need for months.
Here is the honest truth: to build real, production-ready AI products on AWS in 2026, you primarily need five services. These five services appear in almost every AI architecture you will encounter. They work together in a pattern that repeats across different use cases. Once you understand them, you can build most of what you see being built in enterprise AI teams.
The five services are:
This guide explains each one from scratch, shows you where it fits in AI architectures, and ends with a complete project that uses all five together. By the end, you will have built something real.
Amazon Bedrock is the AWS service that gives you access to large AI models, Claude (from Anthropic), Llama (from Meta), Mistral, and Amazon's own Titan models, via a simple API.
Before Bedrock, using AI models in production meant either:
Bedrock changes this. You call a model the same way you call any other AWS service, using your existing AWS credentials, the same IAM permissions you already understand, and billing that goes straight to your AWS account. No separate sign-up, no separate API key, no separate billing.
This matters for planning. Bedrock pricing is per-token:
|
Model |
Input (per 1K tokens) |
Output (per 1K tokens) |
|---|---|---|
|
Claude 3 Haiku |
$0.00025 |
$0.00125 |
|
Claude 3.5 Haiku |
$0.0008 |
$0.004 |
|
Claude 3.5 Sonnet |
$0.003 |
$0.015 |
|
Amazon Titan Text Lite |
$0.00015 |
$0.0002 |
A token is roughly 4 characters of text. A 1,000-word article is approximately 1,300 tokens. At Claude 3 Haiku pricing, processing that article costs $0.0004, less than a tenth of a cent.
For context: an enterprise application making 10,000 requests per day with average 500 tokens per request would spend roughly $0.625/day on Claude 3 Haiku. That is $228/year, not free, but very affordable.
User request
↓
Lambda (your business logic)
↓
Bedrock (AI model call)
↓
AI response back to Lambda
↓
Lambda formats and returns response to user
Bedrock is always in the middle. It never faces the internet directly. Lambda calls it on behalf of users.
bash
# Step 1: Enable model access in the AWS Console
# Go to: AWS Console → Amazon Bedrock → Model access → Manage model access
# Enable: Anthropic Claude 3 Haiku
# Approval is usually instant
# Step 2: Test from the command line
aws bedrock-runtime invoke-model \
--region us-east-1 \
--model-id anthropic.claude-3-haiku-20240307-v1:0 \
--content-type application/json \
--accept application/json \
--body '{
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 300,
"messages": [
{"role": "user", "content": "Explain what Amazon Bedrock is in 2 sentences for a beginner."}
]
}' \
output.json && cat output.json | python3 -m json.tool
You should see a response like:
json
{
"content": [{"text": "Amazon Bedrock is an AWS service that gives developers easy access to powerful AI models from multiple companies through a simple API. It handles all the infrastructure complexity so you can focus on building applications rather than managing AI model deployments."}],
"usage": {"input_tokens": 25, "output_tokens": 47}
}
python
# bedrock_demo.py
import boto3
import json
bedrock = boto3.client('bedrock-runtime', region_name='us-east-1')
def analyse_text(text: str) -> dict:
"""
Analyse any text and return sentiment, key topics, and a one-line summary.
Great for analysing customer feedback, reviews, or documents.
"""
prompt = f"""Analyse this text and return ONLY valid JSON:
{{
"sentiment": "POSITIVE|NEUTRAL|NEGATIVE",
"confidence": 0.0-1.0,
"keyTopics": ["topic1", "topic2", "topic3"],
"summary": "one sentence summary",
"wordCount": number
}}
Text to analyse:
{text}"""
response = bedrock.invoke_model(
modelId='anthropic.claude-3-haiku-20240307-v1:0',
body=json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 400,
"temperature": 0.1,
"messages": [{"role": "user", "content": prompt}]
})
)
result = json.loads(response['body'].read())
content = result['content'][0]['text'].replace('```json', '').replace('```', '').strip()
return {
'analysis': json.loads(content),
'tokensUsed': result['usage']['input_tokens'] + result['usage']['output_tokens'],
'estimatedCostUsd': round(
(result['usage']['input_tokens'] / 1000 * 0.00025) +
(result['usage']['output_tokens'] / 1000 * 0.00125), 8
)
}
# Test it
sample_text = """
The new product update is fantastic! The interface is so much cleaner
and the performance improvements are noticeable. The team did a great
job. Only small issue is the export feature is a bit slower than before,
but overall a massive improvement.
"""
result = analyse_text(sample_text)
print(json.dumps(result, indent=2))
Run it:
bash
pip install boto3
python3 bedrock_demo.py
What this teaches you: How Bedrock works, how to structure prompts for JSON output, and how to calculate costs. These patterns appear in every AI application you will build.
AWS Lambda runs your code without you having to manage any servers. You write a function. AWS runs it when triggered. You pay only for the milliseconds your code actually runs.
Before serverless, deploying backend code meant:
Lambda eliminates all of this. You write the function, deploy it, and Lambda handles everything else. It scales automatically, from zero requests to a million requests without any configuration changes.
The Handler Function
Every Lambda function has a handler, the entry point that receives the event and returns a response:
python
def lambda_handler(event, context):
# event: the input (from API Gateway, S3 event, schedule, etc.)
# context: metadata about the invocation (request ID, remaining time, etc.)
# Your logic here
return {
'statusCode': 200,
'body': 'Hello from Lambda'
}
Cold Starts vs Warm Starts
When Lambda has not been invoked recently, the first invocation takes longer (100ms-1s extra) while AWS initialises the execution environment. This is called a cold start. Subsequent invocations on the same execution environment are warm and fast.
For AI applications: put your AWS client initialisations (boto3 clients, database connections) outside the handler function. They persist across warm invocations:
python
# CORRECT — initialised once, reused across warm invocations
import boto3
bedrock = boto3.client('bedrock-runtime', region_name='us-east-1')
dynamodb = boto3.resource('dynamodb', region_name='eu-west-1')
def lambda_handler(event, context):
# bedrock and dynamodb are already initialised — fast!
response = bedrock.invoke_model(...)
return {'statusCode': 200}
# WRONG — re-initialised on every invocation
def lambda_handler(event, context):
bedrock = boto3.client('bedrock-runtime', region_name='us-east-1') # Slow!
response = bedrock.invoke_model(...)
return {'statusCode': 200}
Memory and Timeout Settings
Lambda memory (128MB–10GB) also controls CPU allocation. More memory = more CPU = faster execution. For AI applications calling Bedrock:
What Lambda Costs
Lambda pricing has two components:
A Lambda with 512MB running 1,000ms (1 second) costs $0.0000083 per invocation. At 100,000 invocations per month: $0.83. Essentially free for most applications.
bash
REGION="eu-west-1"
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
# Create execution role
cat > lambda-trust.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "lambda.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}
EOF
aws iam create-role \
--role-name my-first-lambda-role \
--assume-role-policy-document file://lambda-trust.json
# Attach basic execution policy (CloudWatch logging)
aws iam attach-role-policy \
--role-name my-first-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
# Add Bedrock permission
cat > bedrock-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["bedrock:InvokeModel"],
"Resource": "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-haiku-20240307-v1:0"
}]
}
EOF
aws iam put-role-policy \
--role-name my-first-lambda-role \
--policy-name bedrock-access \
--policy-document file://bedrock-policy.json
LAMBDA_ROLE_ARN=$(aws iam get-role \
--role-name my-first-lambda-role \
--query 'Role.Arn' --output text)
# Wait for IAM to propagate
sleep 10
# Write the Lambda function
cat > function.py << 'EOF'
import boto3
import json
# Initialised outside handler — reused across warm invocations
bedrock = boto3.client('bedrock-runtime', region_name='us-east-1')
def lambda_handler(event, context):
body = json.loads(event.get('body', '{}'))
text = body.get('text', '')
if not text:
return {
'statusCode': 400,
'body': json.dumps({'error': 'text is required'})
}
response = bedrock.invoke_model(
modelId='anthropic.claude-3-haiku-20240307-v1:0',
body=json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 200,
"messages": [{"role": "user", "content": f"Summarise this in one sentence: {text}"}]
})
)
result = json.loads(response['body'].read())
summary = result['content'][0]['text']
return {
'statusCode': 200,
'headers': {'Content-Type': 'application/json'},
'body': json.dumps({'summary': summary})
}
EOF
zip function.zip function.py
# Deploy
aws lambda create-function \
--function-name my-first-ai-lambda \
--runtime python3.12 \
--role $LAMBDA_ROLE_ARN \
--handler function.lambda_handler \
--zip-file fileb://function.zip \
--timeout 30 \
--memory-size 512 \
--region $REGION
echo "Lambda deployed"
# Test it directly
aws lambda invoke \
--function-name my-first-ai-lambda \
--payload '{"body": "{\"text\": \"Amazon Web Services is a subsidiary of Amazon that provides on-demand cloud computing platforms and APIs to individuals, companies, and governments.\"}"}' \
--cli-binary-format raw-in-base64-out \
response.json \
--region $REGION
cat response.json
What this teaches you: The Lambda lifecycle, cold start optimisation, the handler pattern, and how to deploy and test a function. Every AI application you build will have Lambda functions following this pattern.
API Gateway creates HTTP endpoints that trigger your Lambda functions. Without API Gateway, your Lambda function has no URL, there is no way for a web browser, mobile app, or another service to call it over the internet.
API Gateway sits in front of your Lambda and:
AWS offers two types of API Gateway. For most AI applications, use HTTP API, it is simpler, faster, and cheaper:
|
Feature |
HTTP API |
REST API |
|---|---|---|
|
Cost |
$1/million requests |
$3.50/million requests |
|
Latency |
Lower |
Higher |
|
API key auth |
❌ |
✅ |
|
JWT auth |
✅ |
✅ |
|
Request/response transformation |
Limited |
Full |
|
Best for |
Most AI APIs |
Enterprise API management |
Rule of thumb: Start with HTTP API. Switch to REST API only if you need built-in API key management.
bash
# Create HTTP API
API_ID=$(aws apigatewayv2 create-api \
--name "my-ai-api" \
--protocol-type HTTP \
--cors-configuration \
AllowOrigins='*' \
AllowHeaders='Content-Type' \
AllowMethods='POST,GET,OPTIONS' \
--region $REGION \
--query 'ApiId' \
--output text)
echo "API ID: $API_ID"
# Connect API Gateway to your Lambda
INTEGRATION_ID=$(aws apigatewayv2 create-integration \
--api-id $API_ID \
--integration-type AWS_PROXY \
--integration-uri arn:aws:lambda:$REGION:$ACCOUNT_ID:function:my-first-ai-lambda \
--payload-format-version 2.0 \
--region $REGION \
--query 'IntegrationId' \
--output text)
# Create a route — POST requests to /summarise call the Lambda
aws apigatewayv2 create-route \
--api-id $API_ID \
--route-key 'POST /summarise' \
--target integrations/$INTEGRATION_ID \
--region $REGION
# Deploy the API
aws apigatewayv2 create-stage \
--api-id $API_ID \
--stage-name production \
--auto-deploy \
--region $REGION
# Allow API Gateway to invoke the Lambda function
aws lambda add-permission \
--function-name my-first-ai-lambda \
--statement-id allow-api-gateway \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:$REGION:$ACCOUNT_ID:$API_ID/*/*" \
--region $REGION
# Get the URL
API_URL=$(aws apigatewayv2 get-api \
--api-id $API_ID \
--region $REGION \
--query 'ApiEndpoint' \
--output text)
echo ""
echo "Your API is live at: $API_URL/production/summarise"
echo ""
# Test it with curl
curl -X POST $API_URL/production/summarise \
-H "Content-Type: application/json" \
-d '{"text": "AWS Lambda is a serverless computing service that runs code without provisioning or managing servers, automatically scaling from zero to thousands of concurrent executions."}' \
| python3 -m json.tool
When a user calls your API, here is exactly what happens step by step:
1. User sends POST request to:
https://abc123.execute-api.eu-west-1.amazonaws.com/production/summarise
2. API Gateway receives the request
- Checks the route matches (POST /summarise → yes)
- Applies any auth checks (none configured yet)
- Applies rate limits (none configured yet)
- Logs the request to CloudWatch
3. API Gateway calls your Lambda function with an event object:
{
"requestContext": {"http": {"method": "POST"}},
"headers": {"content-type": "application/json"},
"body": "{\"text\": \"AWS Lambda is...\"}",
"rawPath": "/summarise"
}
4. Lambda runs your handler function
- Parses the body
- Calls Bedrock
- Returns a response dict
5. API Gateway receives the Lambda response
- Returns it to the user as an HTTP response
Total time: typically 1-3 seconds for Bedrock calls
What this teaches you: The request/response flow that underpins every web-facing AI application. Understanding this flow is essential for debugging when things go wrong.
DynamoDB is AWS's managed NoSQL database. It is designed for applications that need:
For AI applications, DynamoDB is used for:
Tables, Items, and Attributes
DynamoDB organises data differently from SQL databases:
SQL Database:
TABLE: users
COLUMNS: id, name, email, created_at
ROW: 1, "Emma", "[email protected]", "2026-01-01"
DynamoDB:
TABLE: users
ITEM: {"userId": "u001", "name": "Emma", "email": "[email protected]",
"createdAt": "2026-01-01", "preferences": {"theme": "dark"}}
Key difference: DynamoDB items can have different attributes.
Item 1 might have "preferences". Item 2 might not. That's fine.
Primary Key: The Most Important Concept
Every DynamoDB table has a primary key. Items are stored and retrieved by this key. Getting this right is the single most important DynamoDB design decision.
userId.sessionId, sorted by timestamp.For AI conversation history: sessionId (partition) + timestamp (sort) is the standard pattern.
On-Demand vs Provisioned Capacity
Use On-Demand (pay per read/write) unless you have predictable, high-volume traffic. On-demand is more expensive per operation but requires zero capacity planning and never throttles you unexpectedly.
bash
# Table for storing AI conversation history
# Partition key: sessionId (groups all messages in a conversation)
# Sort key: timestamp (orders messages chronologically within a session)
aws dynamodb create-table \
--table-name ai-conversations \
--attribute-definitions \
AttributeName=sessionId,AttributeType=S \
AttributeName=timestamp,AttributeType=S \
--key-schema \
AttributeName=sessionId,KeyType=HASH \
AttributeName=timestamp,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--region $REGION
# Wait for table to be active
aws dynamodb wait table-exists --table-name ai-conversations --region $REGION
echo "Table created"
# Add some test data
aws dynamodb put-item \
--table-name ai-conversations \
--item '{
"sessionId": {"S": "session-001"},
"timestamp": {"S": "2026-06-15T10:00:00Z"},
"role": {"S": "user"},
"content": {"S": "What is Amazon Bedrock?"},
"tokenCount": {"N": "8"}
}' \
--region $REGION
aws dynamodb put-item \
--table-name ai-conversations \
--item '{
"sessionId": {"S": "session-001"},
"timestamp": {"S": "2026-06-15T10:00:05Z"},
"role": {"S": "assistant"},
"content": {"S": "Amazon Bedrock is an AWS service that provides access to foundation AI models via API."},
"tokenCount": {"N": "23"}
}' \
--region $REGION
# Retrieve the conversation
aws dynamodb query \
--table-name ai-conversations \
--key-condition-expression 'sessionId = :sid' \
--expression-attribute-values '{":sid": {"S": "session-001"}}' \
--region $REGION | python3 -m json.tool
python
# conversation_lambda.py
# A conversational AI that remembers what was said earlier in the session
import boto3
import json
import time
from datetime import datetime, timezone
bedrock = boto3.client('bedrock-runtime', region_name='us-east-1')
dynamodb = boto3.resource('dynamodb', region_name='eu-west-1')
table = dynamodb.Table('ai-conversations')
SYSTEM_PROMPT = "You are a helpful assistant. Be concise and friendly."
MAX_HISTORY_MESSAGES = 10 # Keep last 10 messages for context
def get_conversation_history(session_id: str) -> list:
"""Retrieve the last N messages for a session"""
response = table.query(
KeyConditionExpression=boto3.dynamodb.conditions.Key('sessionId').eq(session_id),
ScanIndexForward=True, # oldest first
Limit=MAX_HISTORY_MESSAGES
)
items = response.get('Items', [])
# Convert DynamoDB items to Bedrock message format
return [
{"role": item['role'], "content": item['content']}
for item in items
]
def save_message(session_id: str, role: str, content: str, token_count: int = 0):
"""Save a message to DynamoDB"""
table.put_item(Item={
'sessionId': session_id,
'timestamp': datetime.now(timezone.utc).isoformat(),
'role': role,
'content': content,
'tokenCount': token_count
})
def lambda_handler(event, context):
body = json.loads(event.get('body', '{}'))
session_id = body.get('sessionId', 'default-session')
user_message = body.get('message', '').strip()
if not user_message:
return {
'statusCode': 400,
'body': json.dumps({'error': 'message is required'})
}
# Get conversation history from DynamoDB
history = get_conversation_history(session_id)
# Add the new user message to history
messages = history + [{"role": "user", "content": user_message}]
# Call Bedrock with the full conversation context
response = bedrock.invoke_model(
modelId='anthropic.claude-3-haiku-20240307-v1:0',
body=json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 500,
"system": SYSTEM_PROMPT,
"messages": messages
})
)
result = json.loads(response['body'].read())
assistant_reply = result['content'][0]['text']
input_tokens = result['usage']['input_tokens']
output_tokens = result['usage']['output_tokens']
# Save both messages to DynamoDB for future context
save_message(session_id, 'user', user_message, input_tokens)
save_message(session_id, 'assistant', assistant_reply, output_tokens)
return {
'statusCode': 200,
'headers': {'Content-Type': 'application/json'},
'body': json.dumps({
'reply': assistant_reply,
'sessionId': session_id,
'usage': {
'inputTokens': input_tokens,
'outputTokens': output_tokens
}
})
}
Test a multi-turn conversation:
bash
# Turn 1
curl -X POST $API_URL/production/chat \
-H "Content-Type: application/json" \
-d '{"sessionId": "test-session-001", "message": "My name is Emma ,and I am a cloud architect."}'
# Turn 2 — the AI should remember your name
curl -X POST $API_URL/production/chat \
-H "Content-Type: application/json" \
-d '{"sessionId": "test-session-001", "message": "What is my name and job title?"}'
The AI remembers your name because DynamoDB stores the conversation history, which is included in every subsequent request. Without DynamoDB, the AI would start fresh every time and forget everything.
What this teaches you: How to give AI memory using DynamoDB, the conversation context pattern, and the query-by-partition-key pattern you will use constantly in AI applications.
CloudWatch is AWS's monitoring, logging, and alerting service. When your AI application is running in production, CloudWatch tells you:
Without CloudWatch, your AI application is a black box. You will not know it has a problem until a user complains. With CloudWatch, you will know before users notice.
1. Logs: What Happened
Every Lambda function automatically sends its output to CloudWatch Logs. Every print() statement in your Lambda appears in CloudWatch. Every error. Every exception stack trace.
python
# In your Lambda function
import logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
logger.info(f"Processing request: {context.aws_request_id}")
try:
result = call_bedrock(...)
logger.info(f"Bedrock call succeeded: {result['totalTokens']} tokens")
return success_response(result)
except Exception as e:
# This appears in CloudWatch with full stack trace
logger.error(f"Bedrock call failed: {str(e)}", exc_info=True)
return error_response(500, "Internal error")
Pro tip: use structured JSON logging. It makes CloudWatch Logs Insights queries possible:
python
import json
def log(level: str, event: str, **kwargs):
"""Write structured JSON log entry"""
print(json.dumps({
"level": level,
"event": event,
**kwargs
}))
# Usage in your Lambda
log("INFO", "bedrock_call_complete",
tokens=287,
latency_ms=1842,
cost_usd=0.00000897)
Now you can query your logs like a database:
sql
-- Find all requests that cost more than $0.01
fields @timestamp, cost_usd, tokens
| filter event = "bedrock_call_complete" and cost_usd > 0.01
| sort cost_usd desc
2. Metrics: How Things Are Performing
CloudWatch Metrics are numerical data points over time. Lambda automatically creates metrics for you: invocation count, error count, duration, throttles.
You can also create custom metrics for your AI-specific measurements:
python
cloudwatch = boto3.client('cloudwatch', region_name='eu-west-1')
def publish_ai_metrics(tokens: int, cost: float, latency: int, success: bool):
"""Publish custom metrics to CloudWatch"""
cloudwatch.put_metric_data(
Namespace='MyAIApp/Metrics',
MetricData=[
{
'MetricName': 'TokensConsumed',
'Value': tokens,
'Unit': 'Count'
},
{
'MetricName': 'EstimatedCostUSD',
'Value': cost,
'Unit': 'None'
},
{
'MetricName': 'BedrockLatencyMs',
'Value': latency,
'Unit': 'Milliseconds'
},
{
'MetricName': 'SuccessfulRequests' if success else 'FailedRequests',
'Value': 1,
'Unit': 'Count'
}
]
)
3. Alarms: Know Before Your Users Do
Alarms watch a metric and notify you when it crosses a threshold:
bash
# Alarm: Lambda error rate > 5% over 5 minutes
aws cloudwatch put-metric-alarm \
--alarm-name "AI-App-High-Error-Rate" \
--alarm-description "More than 5% of requests are failing" \
--namespace AWS/Lambda \
--metric-name Errors \
--dimensions Name=FunctionName,Value=my-first-ai-lambda \
--statistic Sum \
--period 300 \
--evaluation-periods 1 \
--threshold 5 \
--comparison-operator GreaterThanThreshold \
--alarm-actions arn:aws:sns:$REGION:$ACCOUNT_ID:my-alert-topic \
--region $REGION
# Alarm: Lambda duration P95 > 20 seconds (approaching timeout)
aws cloudwatch put-metric-alarm \
--alarm-name "AI-App-High-Latency" \
--alarm-description "P95 Lambda duration is approaching timeout" \
--namespace AWS/Lambda \
--metric-name Duration \
--dimensions Name=FunctionName,Value=my-first-ai-lambda \
--extended-statistic p95 \
--period 300 \
--evaluation-periods 2 \
--threshold 20000 \
--comparison-operator GreaterThanThreshold \
--alarm-actions arn:aws:sns:$REGION:$ACCOUNT_ID:my-alert-topic \
--region $REGION
# Alarm: Daily AI cost > $10
aws cloudwatch put-metric-alarm \
--alarm-name "AI-App-Daily-Cost-High" \
--alarm-description "Daily AI spend exceeded $10" \
--namespace MyAIApp/Metrics \
--metric-name EstimatedCostUSD \
--statistic Sum \
--period 86400 \
--evaluation-periods 1 \
--threshold 10 \
--comparison-operator GreaterThanThreshold \
--alarm-actions arn:aws:sns:$REGION:$ACCOUNT_ID:my-alert-topic \
--region $REGION
echo "Alarms created"
Powerful CloudWatch Logs Insights Queries
Once your Lambda is writing structured logs, these queries become your debugging superpower:
sql
-- Average Bedrock latency over time
fields @timestamp, latency_ms
| filter event = "bedrock_call_complete"
| stats avg(latency_ms) as avg_latency by bin(1h)
| sort @timestamp asc
-- Top 10 most expensive requests today
fields @timestamp, cost_usd, tokens, request_id
| filter event = "bedrock_call_complete"
| filter @timestamp > ago(1d)
| sort cost_usd desc
| limit 10
-- Error breakdown by error type
fields @timestamp, error_type, error_message
| filter level = "ERROR"
| stats count(*) as error_count by error_type
| sort error_count desc
-- Success rate by hour
fields @timestamp
| filter event = "bedrock_call_complete" or event = "bedrock_call_failed"
| stats count(*) as total,
sum(event = "bedrock_call_complete") as successes
by bin(1h)
| sort @timestamp asc
Now we build something that uses all five services in a real, complete application, a multi-turn AI assistant with memory, rate limiting, cost tracking, and monitoring.
python
# complete_ai_assistant.py
# Uses all 5 services: Bedrock, Lambda, DynamoDB, CloudWatch
# (API Gateway is the trigger — configured separately)
import boto3
import json
import time
import logging
from datetime import datetime, timezone
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# ── Service 1: Amazon Bedrock ──────────────────
bedrock = boto3.client('bedrock-runtime', region_name='us-east-1')
# ── Service 4: Amazon DynamoDB ─────────────────
dynamodb = boto3.resource('dynamodb', region_name='eu-west-1')
conversations = dynamodb.Table('ai-conversations')
usage_tracking = dynamodb.Table('ai-usage')
# ── Service 5: Amazon CloudWatch ───────────────
cloudwatch = boto3.client('cloudwatch', region_name='eu-west-1')
MODEL_ID = 'anthropic.claude-3-haiku-20240307-v1:0'
DAILY_TOKEN_LIMIT = 100_000
MAX_HISTORY = 10
def structured_log(level: str, event: str, **kwargs):
"""Structured JSON logging for CloudWatch Logs Insights"""
print(json.dumps({
"level": level,
"event": event,
"timestamp": datetime.now(timezone.utc).isoformat(),
**kwargs
}))
def get_history(session_id: str) -> list:
"""Fetch conversation history from DynamoDB"""
response = conversations.query(
KeyConditionExpression=boto3.dynamodb.conditions.Key('sessionId').eq(session_id),
ScanIndexForward=True,
Limit=MAX_HISTORY
)
return [
{"role": item['role'], "content": item['content']}
for item in response.get('Items', [])
]
def save_messages(session_id: str, user_msg: str, assistant_msg: str):
"""Save both messages to DynamoDB"""
now = datetime.now(timezone.utc).isoformat()
ts_user = now
ts_assistant = now.replace('Z', '.001Z') if 'Z' in now else now + '.001'
with conversations.batch_writer() as batch:
batch.put_item(Item={
'sessionId': session_id,
'timestamp': ts_user,
'role': 'user',
'content': user_msg
})
batch.put_item(Item={
'sessionId': session_id,
'timestamp': ts_assistant,
'role': 'assistant',
'content': assistant_msg
})
def check_rate_limit(session_id: str, tokens_needed: int) -> bool:
"""Check and update daily token usage. Returns True if allowed."""
today = datetime.now(timezone.utc).strftime('%Y-%m-%d')
try:
usage_tracking.update_item(
Key={'sessionId': session_id, 'date': today},
UpdateExpression='ADD tokensUsed :t',
ConditionExpression='attribute_not_exists(tokensUsed) OR tokensUsed < :limit',
ExpressionAttributeValues={':t': tokens_needed, ':limit': DAILY_TOKEN_LIMIT}
)
return True
except dynamodb.meta.client.exceptions.ConditionalCheckFailedException:
return False
def publish_metrics(tokens: int, cost: float, latency: int, success: bool):
"""Publish metrics to CloudWatch"""
try:
cloudwatch.put_metric_data(
Namespace='AIAssistant/Metrics',
MetricData=[
{'MetricName': 'TokensConsumed', 'Value': tokens, 'Unit': 'Count'},
{'MetricName': 'EstimatedCostUSD', 'Value': cost, 'Unit': 'None'},
{'MetricName': 'BedrockLatencyMs', 'Value': latency, 'Unit': 'Milliseconds'},
{
'MetricName': 'SuccessfulRequests' if success else 'FailedRequests',
'Value': 1,
'Unit': 'Count'
}
]
)
except Exception:
pass # Never fail the main request for metrics
def lambda_handler(event, context):
"""
── Service 3: API Gateway triggers this ──
Receives POST /chat with {sessionId, message}
Returns AI response with usage info
"""
request_id = context.aws_request_id
# Handle CORS
if event.get('requestContext', {}).get('http', {}).get('method') == 'OPTIONS':
return {'statusCode': 200,
'headers': {'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'Content-Type',
'Access-Control-Allow-Methods': 'POST,OPTIONS'},
'body': ''}
# Parse input
try:
body = json.loads(event.get('body', '{}'))
except json.JSONDecodeError:
return {'statusCode': 400,
'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'},
'body': json.dumps({'error': 'Invalid JSON'})}
session_id = body.get('sessionId', 'default')
user_message = body.get('message', '').strip()
if not user_message or len(user_message) < 2:
return {'statusCode': 400,
'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'},
'body': json.dumps({'error': 'message must be at least 2 characters'})}
if len(user_message) > 2000:
return {'statusCode': 400,
'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'},
'body': json.dumps({'error': 'message must not exceed 2000 characters'})}
structured_log("INFO", "request_received",
request_id=request_id,
session_id=session_id,
message_length=len(user_message))
# ── Service 4: DynamoDB — Check rate limit ──
estimated_tokens = len(user_message) // 4 + 500
if not check_rate_limit(session_id, estimated_tokens):
structured_log("WARN", "rate_limit_exceeded", session_id=session_id)
return {'statusCode': 429,
'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'},
'body': json.dumps({'error': f'Daily limit of {DAILY_TOKEN_LIMIT:,} tokens exceeded. Resets at midnight UTC.'})}
# ── Service 4: DynamoDB — Load conversation history ──
history = get_history(session_id)
messages = history + [{"role": "user", "content": user_message}]
# ── Service 1: Amazon Bedrock — Call Claude ──
try:
bedrock_start = time.time()
response = bedrock.invoke_model(
modelId=MODEL_ID,
body=json.dumps({
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": 800,
"temperature": 0.7,
"system": "You are a helpful, knowledgeable assistant. Give clear, concise answers.",
"messages": messages
})
)
latency_ms = int((time.time() - bedrock_start) * 1000)
result = json.loads(response['body'].read())
reply = result['content'][0]['text']
input_tokens = result['usage']['input_tokens']
output_tokens = result['usage']['output_tokens']
total_tokens = input_tokens + output_tokens
cost_usd = round(
(input_tokens / 1000 * 0.00025) + (output_tokens / 1000 * 0.00125), 8
)
structured_log("INFO", "bedrock_call_complete",
request_id=request_id,
session_id=session_id,
input_tokens=input_tokens,
output_tokens=output_tokens,
latency_ms=latency_ms,
cost_usd=cost_usd)
# ── Service 4: DynamoDB — Save conversation ──
save_messages(session_id, user_message, reply)
# ── Service 5: CloudWatch — Publish metrics ──
publish_metrics(total_tokens, cost_usd, latency_ms, True)
# ── Service 3: API Gateway — Return response ──
return {
'statusCode': 200,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'X-Request-ID': request_id
},
'body': json.dumps({
'reply': reply,
'sessionId': session_id,
'usage': {
'inputTokens': input_tokens,
'outputTokens': output_tokens,
'estimatedCostUsd': cost_usd,
'latencyMs': latency_ms
}
})
}
except bedrock.exceptions.ThrottlingException:
publish_metrics(0, 0, 0, False)
return {'statusCode': 503,
'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'},
'body': json.dumps({'error': 'AI service busy. Please retry in 30 seconds.'})}
except Exception as e:
structured_log("ERROR", "request_failed",
request_id=request_id,
error_type=type(e).__name__,
error=str(e))
publish_metrics(0, 0, 0, False)
return {'statusCode': 500,
'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'},
'body': json.dumps({'error': 'Something went wrong. Please try again.'})}
Deploy and test:
bash
# Create usage tracking table
aws dynamodb create-table \
--table-name ai-usage \
--attribute-definitions \
AttributeName=sessionId,AttributeType=S \
AttributeName=date,AttributeType=S \
--key-schema \
AttributeName=sessionId,KeyType=HASH \
AttributeName=date,KeyType=RANGE \
--billing-mode PAY_PER_REQUEST \
--region $REGION
# Deploy the complete assistant
zip complete-assistant.zip complete_ai_assistant.py
aws lambda update-function-code \
--function-name my-first-ai-lambda \
--zip-file fileb://complete-assistant.zip \
--region $REGION
# Add DynamoDB permissions to Lambda role
cat > dynamodb-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:UpdateItem",
"dynamodb:Query",
"dynamodb:BatchWriteItem"
],
"Resource": [
"arn:aws:dynamodb:$REGION:$ACCOUNT_ID:table/ai-conversations",
"arn:aws:dynamodb:$REGION:$ACCOUNT_ID:table/ai-usage"
]
}]
}
EOF
aws iam put-role-policy \
--role-name my-first-lambda-role \
--policy-name dynamodb-access \
--policy-document file://dynamodb-policy.json
# Test a multi-turn conversation
SESSION="test-$(date +%s)"
echo "=== Turn 1 ==="
curl -s -X POST $API_URL/production/summarise \
-H "Content-Type: application/json" \
-d "{\"sessionId\": \"$SESSION\", \"message\": \"I am building a startup that helps restaurants manage inventory using AI. Our main challenge is predicting demand accurately.\"}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print('Reply:', d['reply'][:200])"
echo ""
echo "=== Turn 2 — referencing Turn 1 context ==="
curl -s -X POST $API_URL/production/summarise \
-H "Content-Type: application/json" \
-d "{\"sessionId\": \"$SESSION\", \"message\": \"What AWS services would you recommend for the challenge I just described?\"}" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print('Reply:', d['reply'][:300])"
The second response will reference the restaurant inventory startup context from Turn 1, because DynamoDB stored it and it was included in the second Bedrock call.
USER
│
│ POST /chat {"sessionId": "abc", "message": "Hello"}
▼
SERVICE 3: API GATEWAY
│ Receives HTTPS request
│ Routes to Lambda
│ Logs request
▼
SERVICE 2: AWS LAMBDA
│ Parses request
│
├─── SERVICE 4: DYNAMODB
│ │ Checks rate limit
│ │ Loads conversation history
│
├─── SERVICE 1: AMAZON BEDROCK
│ │ Sends messages + history to Claude
│ │ Receives AI response
│
├─── SERVICE 4: DYNAMODB
│ │ Saves user message + AI reply
│
├─── SERVICE 5: CLOUDWATCH
│ │ Publishes cost/latency metrics
│ │ Writes structured logs
│
│ Returns JSON response to API Gateway
▼
SERVICE 3: API GATEWAY
│ Returns response to user
▼
USER receives: {reply, usage, sessionId}
Every production AI application you build will follow this pattern. The services change, the business logic changes, the prompt changes, but the flow is the same.
You now understand the five core services. Here is the natural progression:
Deepen your Bedrock knowledge: Learn Bedrock Knowledge Bases (RAG) from Article 1 of this series to connect AI to your own documents.
Add security to your APIs: Learn AWS Secrets Manager (for storing API keys safely) and AWS WAF (for blocking malicious requests at the API Gateway level).
Scale your database: Learn DynamoDB Global Tables (replicate across regions) and DynamoDB Streams (trigger Lambda when data changes).
Improve observability: Learn CloudWatch Container Insights (for EKS/ECS workloads) and X-Ray (distributed tracing across Lambda + Bedrock calls).
Certify your knowledge: The AWS Solutions Architect Associate (SAA-C03) certification validates your understanding of all five services plus the broader AWS ecosystem. It is the most valuable AWS certification for developers building on the platform.