Module 5: AWS Services for AI (S3, Lambda, SageMaker Basics)
4. S3 + Lambda Integration
Overview
In this capsule you'll connect S3 and Lambda in a complete event-driven flow: S3 trigger → Lambda → processes → S3. Until now you worked with S3 (capsule 02) and Lambda (capsule 03) separately. Here you combine them. Lambda reads a prompt template from S3, takes a new document that fired the trigger, builds a contextualized prompt, invokes the LLM, and writes the response back to S3. It's the fundamental pattern of an AI service on AWS.
Context: In Module 4 you built a pipeline in LocalStack that integrated S3 and Lambda. Now you go deeper into the real integration patterns: S3 Event Notifications, the format of the event Lambda receives, how to avoid infinite loops (Lambda writes to S3 → S3 triggers Lambda again), and how to design flows that scale. By the end, you'll have an event-driven pipeline that processes documents automatically.
S3 Event Notifications → Lambda
How the trigger works
When an object is created (or modified, deleted) in S3, you can configure S3 to send an event to Lambda. Lambda runs automatically with the object's information:
┌──────────────────────┐
Upload doc ──────→│ S3 Bucket │
PUT documents/ │ ai-assets-xxx/ │
│ documents/new-doc.json│
└──────────┬───────────┘
│ S3 Event Notification
│ (s3:ObjectCreated:*)
↓
┌──────────────────────┐
│ Lambda Function │
│ 1. Read S3 event │
│ 2. Read doc from S3 │
│ 3. Read prompt (S3) │
│ 4. Invoke LLM │
│ 5. Write to S3 │
└──────────┬───────────┘
│ put_object
↓
┌──────────────────────┐
│ S3 Bucket │
│ responses/2026/03/08/│
│ resp-abc123.json │
└──────────────────────┘
Configure an S3 Event Notification with boto3
import boto3
import json
import os
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
lambda_client = boto3.client("lambda", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
BUCKET = "ai-assets-dev"
LAMBDA_ARN = os.environ.get(
"PROCESSOR_LAMBDA_ARN",
"arn:aws:lambda:us-east-1:000000000000:function:ai-doc-processor"
)
def configure_s3_trigger(bucket: str, lambda_arn: str, prefix: str) -> dict:
"""Configures S3 to trigger Lambda when a file is uploaded."""
response = s3.put_bucket_notification_configuration(
Bucket=bucket,
NotificationConfiguration={
"LambdaFunctionConfigurations": [
{
"Id": "process-new-documents",
"LambdaFunctionArn": lambda_arn,
"Events": ["s3:ObjectCreated:*"],
"Filter": {
"Key": {
"FilterRules": [
{"Name": "prefix", "Value": prefix},
{"Name": "suffix", "Value": ".json"},
]
}
},
}
]
},
)
print(f"Trigger configured: {bucket}/{prefix}*.json → {lambda_arn}")
return response
configure_s3_trigger(BUCKET, LAMBDA_ARN, "documents/inbox/")
The S3 event Lambda receives
When S3 triggers Lambda, the event has this structure:
# Event Lambda receives when a file is uploaded to S3
event = {
"Records": [
{
"eventVersion": "2.1",
"eventSource": "aws:s3",
"awsRegion": "us-east-1",
"eventTime": "2026-03-08T12:00:00.000Z",
"eventName": "ObjectCreated:Put",
"s3": {
"bucket": {
"name": "ai-assets-dev",
"arn": "arn:aws:s3:::ai-assets-dev",
},
"object": {
"key": "documents/inbox/new-report.json",
"size": 4567,
"eTag": "abc123def456",
},
},
}
]
}
Parse the S3 event in Lambda
from urllib.parse import unquote_plus
def parse_s3_event(event: dict) -> list[dict]:
"""Extracts bucket and key information from an S3 event."""
records = []
for record in event.get("Records", []):
s3_info = record.get("s3", {})
bucket = s3_info.get("bucket", {}).get("name", "")
key = unquote_plus(s3_info.get("object", {}).get("key", ""))
size = s3_info.get("object", {}).get("size", 0)
records.append({
"bucket": bucket,
"key": key,
"size": size,
"event_name": record.get("eventName", ""),
"event_time": record.get("eventTime", ""),
"region": record.get("awsRegion", ""),
})
return records
Handler: S3 Trigger → Process with LLM → S3
Complete flow
# s3_trigger_handler.py — Lambda that processes S3 documents with an LLM
import json
import logging
import os
import time
from datetime import datetime
from urllib.parse import unquote_plus
import boto3
from openai import OpenAI
logger = logging.getLogger()
logger.setLevel(os.environ.get("LOG_LEVEL", "INFO"))
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
openai_client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
BUCKET = os.environ.get("AI_BUCKET", "ai-assets-dev")
PROMPT_KEY = os.environ.get("PROMPT_KEY", "prompts/summarizer/v1/system.txt")
RESPONSE_PREFIX = os.environ.get("RESPONSE_PREFIX", "responses/")
MODEL = os.environ.get("MODEL_NAME", "gpt-4o-mini")
def get_prompt_template() -> str:
"""Reads the prompt template from S3."""
response = s3.get_object(Bucket=BUCKET, Key=PROMPT_KEY)
return response["Body"].read().decode("utf-8")
def get_document(bucket: str, key: str) -> dict:
"""Reads a JSON document from S3."""
response = s3.get_object(Bucket=bucket, Key=key)
content = response["Body"].read().decode("utf-8")
return json.loads(content)
def save_response(request_id: str, response_data: dict) -> str:
"""Saves the inference response to S3."""
now = datetime.utcnow()
key = f"{RESPONSE_PREFIX}{now.strftime('%Y/%m/%d')}/{request_id}.json"
s3.put_object(
Bucket=BUCKET,
Key=key,
Body=json.dumps(response_data, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
)
return key
def process_document(document: dict, prompt_template: str) -> dict:
"""Processes a document using the LLM with the prompt template."""
doc_content = document.get("content", "")
doc_title = document.get("title", "Untitled")
full_prompt = f"{prompt_template}\n\nDocument: {doc_title}\n\n{doc_content}"
response = openai_client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": full_prompt}],
max_tokens=1000,
)
return {
"answer": response.choices[0].message.content,
"model": response.model,
"tokens_used": response.usage.total_tokens,
"input_tokens": response.usage.prompt_tokens,
"output_tokens": response.usage.completion_tokens,
}
def handler(event, context):
"""Lambda triggered by S3 that processes documents with an LLM."""
start_time = time.time()
results = []
prompt_template = get_prompt_template()
for record in event.get("Records", []):
s3_info = record.get("s3", {})
source_bucket = s3_info.get("bucket", {}).get("name", "")
source_key = unquote_plus(s3_info.get("object", {}).get("key", ""))
request_id = f"req-{context.aws_request_id[:8]}"
logger.info(json.dumps({
"event": "processing_document",
"request_id": request_id,
"source": f"s3://{source_bucket}/{source_key}",
}))
try:
document = get_document(source_bucket, source_key)
llm_result = process_document(document, prompt_template)
response_data = {
"request_id": request_id,
"source_key": source_key,
"source_bucket": source_bucket,
"document_title": document.get("title", ""),
"processed_at": datetime.utcnow().isoformat(),
**llm_result,
}
response_key = save_response(request_id, response_data)
logger.info(json.dumps({
"event": "document_processed",
"request_id": request_id,
"response_key": response_key,
"tokens_used": llm_result["tokens_used"],
}))
results.append({
"request_id": request_id,
"source_key": source_key,
"response_key": response_key,
"status": "success",
})
except Exception as e:
logger.error(json.dumps({
"event": "processing_error",
"request_id": request_id,
"source_key": source_key,
"error": str(e),
}))
results.append({
"request_id": request_id,
"source_key": source_key,
"status": "error",
"error": str(e),
})
duration_ms = int((time.time() - start_time) * 1000)
return {
"processed": len(results),
"results": results,
"duration_ms": duration_ms,
}
Avoiding Infinite Loops
The problem
If Lambda writes to the same bucket that triggers it, you can create a loop:
S3 upload (documents/inbox/) → Lambda runs
→ Lambda writes to responses/ → does S3 trigger Lambda again?
If the trigger is configured for s3:ObjectCreated:* without a prefix filter, Lambda writes to S3, S3 triggers Lambda, Lambda writes to S3... infinite loop. Each iteration invokes Lambda and costs money.
Solution 1: Different prefixes (recommended)
# Trigger ONLY on documents/inbox/ (input prefix)
# Lambda writes to responses/ (output prefix)
# The trigger does NOT fire on responses/
configure_s3_trigger(
bucket=BUCKET,
lambda_arn=LAMBDA_ARN,
prefix="documents/inbox/", # Only this prefix triggers Lambda
)
Solution 2: Separate bucket for output
INPUT_BUCKET = "ai-input-dev"
OUTPUT_BUCKET = "ai-output-dev"
# Trigger on INPUT_BUCKET
# Lambda writes to OUTPUT_BUCKET
# No possibility of a loop
Solution 3: Check in the handler
def handler(event, context):
for record in event.get("Records", []):
key = record["s3"]["object"]["key"]
if key.startswith("responses/") or key.startswith("processed/"):
logger.info(f"Skipping output file: {key}")
continue
process_document(record)
Complete Flow: Prompt Template + RAG Document
Pipeline with a dynamic prompt template
# pipeline.py — Complete flow S3 → Lambda → LLM → S3
import boto3
import json
import os
from openai import OpenAI
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
openai_client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
BUCKET = os.environ.get("AI_BUCKET", "ai-assets-dev")
def run_ai_pipeline(
prompt_name: str,
prompt_version: str,
document_collection: str,
document_id: str,
max_tokens: int = 1000,
) -> dict:
"""Runs the complete pipeline: reads prompt + document, invokes LLM, saves response."""
# 1. Read prompt template from S3
prompt_key = f"prompts/{prompt_name}/{prompt_version}/system.txt"
prompt_resp = s3.get_object(Bucket=BUCKET, Key=prompt_key)
prompt_template = prompt_resp["Body"].read().decode("utf-8")
# 2. Read document from S3
doc_key = f"documents/{document_collection}/{document_id}.json"
doc_resp = s3.get_object(Bucket=BUCKET, Key=doc_key)
document = json.loads(doc_resp["Body"].read().decode("utf-8"))
# 3. Build the contextualized prompt
doc_content = document.get("content", "")
doc_title = document.get("title", "")
full_prompt = f"{prompt_template}\n\nTitle: {doc_title}\n\nContent:\n{doc_content}"
# 4. Invoke the LLM
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": full_prompt}],
max_tokens=max_tokens,
)
result = {
"answer": response.choices[0].message.content,
"model": response.model,
"tokens_used": response.usage.total_tokens,
"prompt_name": prompt_name,
"prompt_version": prompt_version,
"document_id": document_id,
"collection": document_collection,
}
# 5. Save the response to S3
from datetime import datetime
now = datetime.utcnow()
response_key = f"responses/{now.strftime('%Y/%m/%d')}/{document_id}-{prompt_name}.json"
s3.put_object(
Bucket=BUCKET,
Key=response_key,
Body=json.dumps(result, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
)
result["response_key"] = response_key
return result
# Usage
output = run_ai_pipeline(
prompt_name="summarizer",
prompt_version="v1",
document_collection="product-docs",
document_id="doc-001",
)
print(f"Response saved to: s3://{BUCKET}/{output['response_key']}")
Batch Processing with S3 + Lambda
Process multiple documents
def batch_process_collection(
prompt_name: str,
prompt_version: str,
collection: str,
) -> dict:
"""Processes all documents in a collection with the given prompt."""
paginator = s3.get_paginator("list_objects_v2")
prefix = f"documents/{collection}/"
doc_keys = []
for page in paginator.paginate(Bucket=BUCKET, Prefix=prefix):
for obj in page.get("Contents", []):
if obj["Key"].endswith(".json"):
doc_keys.append(obj["Key"])
print(f"Found {len(doc_keys)} documents in '{collection}'")
results = {"success": 0, "failed": 0, "errors": []}
for key in doc_keys:
doc_id = key.split("/")[-1].replace(".json", "")
try:
output = run_ai_pipeline(
prompt_name=prompt_name,
prompt_version=prompt_version,
document_collection=collection,
document_id=doc_id,
)
results["success"] += 1
print(f" ✓ {doc_id}: {output['tokens_used']} tokens")
except Exception as e:
results["failed"] += 1
results["errors"].append({"doc_id": doc_id, "error": str(e)})
print(f" ✗ {doc_id}: {str(e)}")
return results
SAM Template with an S3 Trigger
template.yaml for S3 → Lambda
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: S3 + Lambda AI Pipeline — Module 5
Parameters:
OpenAiApiKey:
Type: String
NoEcho: true
BucketName:
Type: String
Default: ai-assets-dev
Resources:
AIBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Ref BucketName
DocProcessorFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: ai-doc-processor
Handler: s3_trigger_handler.handler
Runtime: python3.11
Architectures: [arm64]
MemorySize: 512
Timeout: 120
Environment:
Variables:
OPENAI_API_KEY: !Ref OpenAiApiKey
AI_BUCKET: !Ref BucketName
PROMPT_KEY: prompts/summarizer/v1/system.txt
RESPONSE_PREFIX: responses/
MODEL_NAME: gpt-4o-mini
Policies:
- S3ReadPolicy:
BucketName: !Ref BucketName
- S3CrudPolicy:
BucketName: !Ref BucketName
Events:
NewDocument:
Type: S3
Properties:
Bucket: !Ref AIBucket
Events: s3:ObjectCreated:*
Filter:
S3Key:
Rules:
- Name: prefix
Value: documents/inbox/
- Name: suffix
Value: .json
Outputs:
BucketName:
Value: !Ref AIBucket
ProcessorArn:
Value: !GetAtt DocProcessorFunction.Arn
Testing the Complete Flow
Manual test with boto3
import boto3
import json
import os
import time
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
BUCKET = "ai-assets-dev"
def setup_test_data():
"""Prepares test data in S3."""
s3.put_object(
Bucket=BUCKET,
Key="prompts/summarizer/v1/system.txt",
Body="Summarize the following document in 3 key points.".encode("utf-8"),
)
test_doc = {
"id": "test-doc-001",
"title": "Introduction to Serverless",
"content": (
"Serverless computing lets you run code without managing servers. "
"AWS Lambda is the most popular service for serverless. "
"Functions run in response to events and scale automatically. "
"You pay only for execution time, not for idle servers."
),
}
s3.put_object(
Bucket=BUCKET,
Key="documents/inbox/test-doc-001.json",
Body=json.dumps(test_doc, ensure_ascii=False).encode("utf-8"),
)
print("Test data uploaded")
def verify_response():
"""Verifies that Lambda processed the document and saved the response."""
time.sleep(5)
response = s3.list_objects_v2(Bucket=BUCKET, Prefix="responses/")
contents = response.get("Contents", [])
if not contents:
print("No responses found yet. Lambda may still be processing.")
return
for obj in contents:
print(f"Response found: {obj['Key']}")
resp = s3.get_object(Bucket=BUCKET, Key=obj["Key"])
data = json.loads(resp["Body"].read().decode("utf-8"))
print(f" Document: {data.get('document_title')}")
print(f" Answer: {data.get('answer', '')[:200]}...")
print(f" Tokens: {data.get('tokens_used')}")
setup_test_data()
verify_response()
Troubleshooting
Problem 1: Lambda doesn't trigger when I upload a file to S3
Check the notification configuration and the permissions.
# Check the notification configuration
aws s3api get-bucket-notification-configuration --bucket ai-assets-dev
# Check that Lambda has a permission policy for S3
aws lambda get-policy --function-name ai-doc-processor
# If the permission is missing:
aws lambda add-permission \
--function-name ai-doc-processor \
--statement-id s3-trigger \
--action lambda:InvokeFunction \
--principal s3.amazonaws.com \
--source-arn arn:aws:s3:::ai-assets-dev
Problem 2: Infinite loop — Lambda triggers itself
Lambda writes to a prefix that fires another trigger.
# Make sure your trigger filters by the INPUT prefix
# and that Lambda writes to a different OUTPUT prefix
# ✅ Trigger: documents/inbox/*.json
# ✅ Output: responses/2026/03/08/*.json
# ❌ Trigger: documents/*.json (too broad)
# ❌ Output: documents/processed/*.json (same base prefix)
Problem 3: "NoSuchKey" when reading the prompt template
The prompt template doesn't exist in S3 when Lambda runs.
try:
prompt = get_prompt_template()
except s3.exceptions.NoSuchKey:
logger.error(f"Prompt template not found: {PROMPT_KEY}")
prompt = "Summarize the following document concisely."
Problem 4: S3 event with a URL-encoded key
S3 sends URL-encoded keys. Spaces become +.
from urllib.parse import unquote_plus
raw_key = record["s3"]["object"]["key"]
key = unquote_plus(raw_key)
Practical Exercises
Exercise 1: Multi-prompt pipeline
Modify the pipeline so it processes each document with multiple prompts (for example, "summarizer" and "classifier") and saves each result in a separate prefix.
See solution
import boto3
import json
import os
from datetime import datetime
from openai import OpenAI
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
openai_client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
BUCKET = os.environ.get("AI_BUCKET", "ai-assets-dev")
PIPELINES = [
{"prompt_name": "summarizer", "prompt_version": "v1", "output_prefix": "summaries/"},
{"prompt_name": "classifier", "prompt_version": "v1", "output_prefix": "classifications/"},
]
def multi_prompt_handler(event, context):
results = []
for record in event.get("Records", []):
source_key = record["s3"]["object"]["key"]
source_bucket = record["s3"]["bucket"]["name"]
doc_resp = s3.get_object(Bucket=source_bucket, Key=source_key)
document = json.loads(doc_resp["Body"].read().decode("utf-8"))
for pipeline in PIPELINES:
prompt_key = f"prompts/{pipeline['prompt_name']}/{pipeline['prompt_version']}/system.txt"
prompt_resp = s3.get_object(Bucket=BUCKET, Key=prompt_key)
prompt_template = prompt_resp["Body"].read().decode("utf-8")
full_prompt = f"{prompt_template}\n\n{document.get('content', '')}"
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": full_prompt}],
max_tokens=500,
)
doc_id = document.get("id", source_key.split("/")[-1].replace(".json", ""))
now = datetime.utcnow()
output_key = (
f"{pipeline['output_prefix']}{now.strftime('%Y/%m/%d')}/"
f"{doc_id}-{pipeline['prompt_name']}.json"
)
result = {
"document_id": doc_id,
"pipeline": pipeline["prompt_name"],
"answer": response.choices[0].message.content,
"tokens_used": response.usage.total_tokens,
"processed_at": now.isoformat(),
}
s3.put_object(
Bucket=BUCKET,
Key=output_key,
Body=json.dumps(result, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
)
results.append({"doc_id": doc_id, "pipeline": pipeline["prompt_name"], "output": output_key})
return {"processed": len(results), "results": results}
Exercise 2: S3 trigger with deduplication
Implement a mechanism that avoids processing the same document twice. Use a "processed/" prefix in S3 as a record of already-processed documents.
See solution
import boto3
import json
import os
import hashlib
from datetime import datetime
from urllib.parse import unquote_plus
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
BUCKET = os.environ.get("AI_BUCKET", "ai-assets-dev")
PROCESSED_PREFIX = "processed/"
def is_already_processed(source_key: str) -> bool:
"""Checks whether a document has already been processed."""
marker_key = f"{PROCESSED_PREFIX}{hashlib.md5(source_key.encode()).hexdigest()}"
try:
s3.head_object(Bucket=BUCKET, Key=marker_key)
return True
except s3.exceptions.ClientError as e:
if e.response["Error"]["Code"] == "404":
return False
raise
def mark_as_processed(source_key: str, result_key: str):
"""Marks a document as processed."""
marker_key = f"{PROCESSED_PREFIX}{hashlib.md5(source_key.encode()).hexdigest()}"
s3.put_object(
Bucket=BUCKET,
Key=marker_key,
Body=json.dumps({
"source_key": source_key,
"result_key": result_key,
"processed_at": datetime.utcnow().isoformat(),
}).encode("utf-8"),
)
def handler(event, context):
results = []
for record in event.get("Records", []):
source_key = unquote_plus(record["s3"]["object"]["key"])
if is_already_processed(source_key):
results.append({"key": source_key, "status": "skipped", "reason": "already processed"})
continue
try:
result_key = process_and_save(source_key)
mark_as_processed(source_key, result_key)
results.append({"key": source_key, "status": "processed", "result": result_key})
except Exception as e:
results.append({"key": source_key, "status": "error", "error": str(e)})
return {"results": results}
def process_and_save(source_key: str) -> str:
"""Processes the document and returns the result key."""
doc_resp = s3.get_object(Bucket=BUCKET, Key=source_key)
document = json.loads(doc_resp["Body"].read().decode("utf-8"))
now = datetime.utcnow()
doc_id = source_key.split("/")[-1].replace(".json", "")
result_key = f"responses/{now.strftime('%Y/%m/%d')}/{doc_id}.json"
s3.put_object(
Bucket=BUCKET,
Key=result_key,
Body=json.dumps({"processed": True, "source": source_key}).encode("utf-8"),
)
return result_key
Exercise 3: Pipeline with A/B prompt versioning
Implement a flow that processes each document with two prompt versions (A/B test) and saves both results to compare.
See solution
import boto3
import json
import os
import time
from datetime import datetime
from openai import OpenAI
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
openai_client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
BUCKET = os.environ.get("AI_BUCKET", "ai-assets-dev")
def ab_test_handler(event, context):
"""Processes each document with two prompt versions for A/B testing."""
versions = ["v1", "v2"]
prompt_name = os.environ.get("PROMPT_NAME", "summarizer")
results = []
for record in event.get("Records", []):
source_key = record["s3"]["object"]["key"]
doc_resp = s3.get_object(Bucket=BUCKET, Key=source_key)
document = json.loads(doc_resp["Body"].read().decode("utf-8"))
doc_id = document.get("id", "unknown")
ab_results = {}
for version in versions:
prompt_key = f"prompts/{prompt_name}/{version}/system.txt"
try:
prompt_resp = s3.get_object(Bucket=BUCKET, Key=prompt_key)
prompt_template = prompt_resp["Body"].read().decode("utf-8")
except Exception:
continue
full_prompt = f"{prompt_template}\n\n{document.get('content', '')}"
start = time.time()
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": full_prompt}],
max_tokens=500,
)
duration = int((time.time() - start) * 1000)
ab_results[version] = {
"answer": response.choices[0].message.content,
"tokens_used": response.usage.total_tokens,
"duration_ms": duration,
}
now = datetime.utcnow()
comparison_key = f"ab-tests/{prompt_name}/{now.strftime('%Y/%m/%d')}/{doc_id}.json"
s3.put_object(
Bucket=BUCKET,
Key=comparison_key,
Body=json.dumps({
"document_id": doc_id,
"prompt_name": prompt_name,
"versions_tested": versions,
"results": ab_results,
"tested_at": now.isoformat(),
}, ensure_ascii=False).encode("utf-8"),
ContentType="application/json",
)
results.append({"doc_id": doc_id, "comparison_key": comparison_key})
return {"ab_tests": len(results), "results": results}
Exercise 4: Pipeline monitor with alerts
Create a function that reviews the responses bucket and generates a pipeline health report: documents processed in the last 24h, errors, average tokens, etc.
See solution
import boto3
import json
import os
from datetime import datetime, timedelta
s3 = boto3.client("s3", endpoint_url=os.environ.get("AWS_ENDPOINT_URL"))
BUCKET = os.environ.get("AI_BUCKET", "ai-assets-dev")
def pipeline_health_check() -> dict:
"""Generates a pipeline health report for the last 24h."""
now = datetime.utcnow()
yesterday = now - timedelta(days=1)
prefixes = [
f"responses/{now.strftime('%Y/%m/%d')}/",
f"responses/{yesterday.strftime('%Y/%m/%d')}/",
]
all_responses = []
for prefix in prefixes:
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=BUCKET, Prefix=prefix):
for obj in page.get("Contents", []):
if obj["LastModified"].replace(tzinfo=None) >= yesterday:
try:
resp = s3.get_object(Bucket=BUCKET, Key=obj["Key"])
data = json.loads(resp["Body"].read().decode("utf-8"))
all_responses.append(data)
except Exception:
pass
if not all_responses:
return {"status": "warning", "message": "No responses in last 24h", "count": 0}
tokens_list = [r.get("tokens_used", 0) for r in all_responses]
errors = [r for r in all_responses if r.get("status") == "error"]
report = {
"status": "healthy" if len(errors) == 0 else "degraded",
"period": f"{yesterday.isoformat()} — {now.isoformat()}",
"total_processed": len(all_responses),
"errors": len(errors),
"error_rate": f"{(len(errors) / len(all_responses)) * 100:.1f}%",
"tokens": {
"total": sum(tokens_list),
"average": round(sum(tokens_list) / len(tokens_list)),
"max": max(tokens_list),
},
"checked_at": now.isoformat(),
}
if len(errors) / max(len(all_responses), 1) > 0.1:
report["alert"] = "Error rate > 10%. Check pipeline configuration."
return report
health = pipeline_health_check()
print(json.dumps(health, indent=2))
Summary
- S3 Event Notifications trigger Lambda automatically when a file is uploaded. Configure prefix and suffix filters for precision.
- The S3 → Lambda → S3 flow is the core pattern of an AI service on AWS: Lambda reads assets (prompts, documents), invokes the LLM, and persists results.
- Avoid infinite loops by using separate input and output prefixes, or different buckets for input and output.
- Parse the S3 event correctly: decode URL-encoded keys with
unquote_plus, handle multiple Records. - The SAM Template defines the S3 → Lambda trigger as code — reproducible and versionable.
- Batch processing lets you process entire document collections with a single prompt or multiple prompts (A/B testing).
Additional Resources
- S3 Event Notifications — Configuring triggers
- Lambda S3 Event Tutorial — Official tutorial
- S3 Event Message Structure — Event format
- SAM S3 Event — SAM template for S3 triggers
- Lambda Permissions for S3 — Required permissions
- Avoiding Recursive Invocations — Preventing loops
- S3 Batch Operations — Native mass processing
- Lambda Dead Letter Queues — Handling async failures