Solving Problems You Can't See: How AWS X-Ray and CloudWatch Provide User-Level Observability in Serverless Microservices Applications
Avoid blind spots by tracking users on apps built on AWS.
Step 1: Deploy the aws-serverless-shopping-cart Sample Application
Step 2: Explore the Tracing Capabilities Already Implemented in the Sample Application
A Brief Tour of the Frontend Code
From Your Browser to API Gateway
AWS X-Ray - Your Application’s Tracing Companion
Step 3: Update the Application to Capture the Cognito User ID in AWS X-Ray
Step 4: Group Application Requests by Registered and Anonymous Users
Step 5: View and Diagnose Issues by Registered and Anonymous Users in the AWS X-Ray Console
- How to configure and implement AWS X-Ray in a serverless application that uses Amazon Cognito, Amazon API Gateway, and AWS Lambda
- How to follow and observe your user requests as they flow through Amazon Cognito, Amazon API Gateway, and AWS Lambda
- How to use the AWS X-Ray SDK for python to instrument your python applications with AWS X-Ray
- How to use AWS X-Ray groups to identify and diagnose applications based on user segmentation
- How to use AWS X-Ray CloudWatch metrics to alarm on AWS X-Ray groups
About | |
---|---|
✅ AWS Level | Advanced - 300 |
⏱ Time to complete | 120 minutes |
💰 Cost to complete | Free when using the AWS Free Tier |
🧩 Prerequisites | - AWS Account - Git client |
💻 Code Sample | aws-serverless-shopping-cart |
📢 Feedback | Any feedback, issues, or just a 👍 / 👎 ? |
⏰ Last Updated | 2023-09-26 |
@tracer.capture_lambda_handler
is all that is needed to create an AWS X-Ray annotation named ColdStart as well as an annotation identifying your service in X-Ray traces. It will also capture exceptions generated by your lambda function as AWS X-Ray metadata.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import CartButton from "@/components/CartButton.vue";
import CartDrawer from "@/components/CartDrawer.vue";
import LoadingOverlay from "@/components/LoadingOverlay.vue";
import Product from "@/components/Product.vue";
import CartQuantityEditor from "@/components/CartQuantityEditor.vue"
Vue.config.productionTip = false
Amplify.configure(config)
Vue.use(VueRouter)
Vue.use(Vuelidate)
Vue.use(VueMask);
Vue.component('cart-button', CartButton)
Vue.component('cart-drawer', CartDrawer)
Vue.component('loading-overlay', LoadingOverlay)
Vue.component('product', Product)
Vue.component('cart-quantity-editor', CartQuantityEditor)
new Vue({
render: h => h(App),
router,
vuetify,
store,
components: {
...components
}
}).$mount('#app')
<router-view />
. You can also see that the cart drawer (<cart-drawer />
) remains constant, even if the path and route for the application changes.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<v-content>
<v-container fluid>
<loading-overlay />
<v-fade-transition mode="out-in">
<router-view></router-view>
</v-fade-transition>
</v-container>
<v-navigation-drawer
style="position:fixed; overflow-y:scroll;"
right
v-model="drawer"
temporary
align-space-around
column
d-flex
>
<cart-drawer />
</v-navigation-drawer>
</v-content>
mounted()
), the shopping cart for the user is retrieved:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<script>
import { mapGetters, mapState } from "vuex";
export default {
name: "app",
data() {
return {
drawer: null
};
},
mounted() {
this.$store.dispatch("fetchCart");
},
computed: {
...mapGetters(["cartSize", "currentUser"]),
...mapState(["cartLoading"])
},
methods: {
logout() {
this.$store.dispatch("logout");
},
toggleDrawer() {
this.drawer = !this.drawer;
}
}
};
</script>
fetchCart()
call is defined as an action in frontend/src/store/actions.js:1
2
3
4
5
6
7
8
9
10
const fetchCart = ({
commit
}) => {
commit("setLoading", {value: true})
getCart()
.then((response) => {
commit("setUpCart", response.products)
commit("setLoading", {value: false})
})
}
getCart()
in frontend/src/backend/api.js:1
2
3
4
5
6
7
export async function getCart() {
return getHeaders(true).then(
headers => API.get("CartAPI", "/cart", {
headers: headers,
withCredentials: true
}))
}
getCart()
function initiates the call to retrieve the cart contents from the aws-serverless-shopping-cart-shop-ListCartFunction<hash> AWS Lambda function through API Gateway using the Amplify JavaScript SDK.<router-view></router-view>
position.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const routes = [{
path: '/',
component: Home
},
{
path: '/auth',
name: 'Authenticator',
component: Auth
}, {
path: '/checkout',
component: Payment,
meta: {
requiresAuth: true
}
}
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<template>
<v-container grid-list-md fluid class="mt-0" pt-0>
<v-layout row wrap>
<v-flex v-for="product in products" :key="product.productId" xs12 lg4 sm6>
<product :product="product" :key="product.productId" />
</v-flex>
</v-layout>
</v-container>
</template>
<script>
export default {
computed: {
products() {
return this.$store.state.products;
}
},
created() {
this.$store.dispatch("fetchProducts");
}
};
</script>
1
2
3
4
5
6
7
const fetchProducts = ({
commit
}) => {
getProducts().then((response) => {
commit("setUpProducts", response.products);
});
}
1
2
3
4
5
6
7
export async function getProducts() {
return getHeaders().then(
headers => API.get("ProductAPI", "/product", {
headers: headers
})
)
}
getProducts()
and getCart()
calls from our frontend code are what initiate our call to retrieve our products and cart contents from our sample applications AWS Lambda functions using API Gateway with the Amplify JavaScript SDK.API.get()
from the Amplify SDK. These endpoints are defined in the frontend/src/aws-exports.js file:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const awsmobile = {
Auth: {
region: process.env.VUE_APP_AWS_REGION,
userPoolId: process.env.VUE_APP_USER_POOL_ID,
userPoolWebClientId: process.env.VUE_APP_USER_POOL_CLIENT_ID
},
API: {
endpoints: [{
name: "CartAPI",
endpoint: process.env.VUE_APP_CART_API_URL
},
{
name: "ProductAPI",
endpoint: process.env.VUE_APP_PRODUCTS_API_URL,
}
]
}
};
1
2
3
4
5
6
def lambda_handler(event, context):
"""
List items in shopping cart.
"""
@tracer.capture_lambda_handler
annotation.getCart()
function in frontend/src/backend/api.js:1
2
3
4
5
6
7
export async function getCart() {
return getHeaders(true).then(
headers => API.get("CartAPI", "/cart", {
headers: headers,
withCredentials: true
}))
}
cognito:username
) and Cognito user ID (sub
) from the token.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import json
import os
import boto3
from aws_xray_sdk.core import xray_recorder
from aws_lambda_powertools import Logger, Tracer
from boto3.dynamodb.conditions import Key
from shared import get_cart_id, get_headers, get_user_sub, get_username, handle_decimal_type
logger = Logger()
tracer = Tracer()
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
def lambda_handler(event, context):
"""
List items in shopping cart.
"""
subsegment = xray_recorder.begin_subsegment('annotations')
cart_id, generated = get_cart_id(event["headers"])
if generated:
subsegment.put_annotation('generated_cart', True)
else:
subsegment.put_annotation('generated_cart', False)
# Because this method can be called anonymously, we need to check there's a logged in user
jwt_token = event["headers"].get("Authorization")
if jwt_token:
user_sub = get_user_sub(jwt_token)
username = get_username(jwt_token)
subsegment.put_annotation('username', username)
key_string = f"user#{user_sub}"
logger.structure_logs(append=True, cart_id=f"user#{user_sub}")
else:
subsegment.put_annotation('username', 'anonymous')
key_string = f"cart#{cart_id}"
logger.structure_logs(append=True, cart_id=f"cart#{cart_id}")
# No need to query database if the cart_id was generated rather than passed into the function
if generated:
logger.info("cart ID was generated in this request, not fetching cart from DB")
product_list = []
else:
logger.info("Fetching cart from DB")
response = table.query(
KeyConditionExpression=Key("pk").eq(key_string)
& Key("sk").begins_with("product#"),
ProjectionExpression="sk,quantity,productDetail",
FilterExpression="quantity > :val", # Only return items with more than 0 quantity
ExpressionAttributeValues={":val": 0},
)
product_list = response.get("Items", [])
for product in product_list:
product.update(
(k, v.replace("product#", "")) for k, v in product.items() if k == "sk"
)
xray_recorder.end_subsegment()
return {
"statusCode": 200,
"headers": get_headers(cart_id),
"body": json.dumps({"products": product_list}, default=handle_decimal_type),
}
- On line 5, you imported the xray_recorder function from the Python AWS X-Ray SDK.
- On line 9, you imported the new get_username function in shared.py that you will add in the next step.
- On line 24, you create a new subsegment called annotations using the X-Ray SDK for Python as soon as your Lambda function handler begins execution.
- On lines 28-32, you create an annotation called generated_cart to indicate whether or not the cart ID was retrieved from the cookie cartId set by the front end user interface.
- On lines 37-38 and 42, you retrieve the username and create an annotation called username which is set to the Cognito username if an authorized login is detected or set to the value 'anonymous'.
- On line 65, you closed the annotations subsegment you created on line 24.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import calendar
import datetime
import os
import uuid
from decimal import Decimal
from http.cookies import SimpleCookie
from aws_lambda_powertools import Tracer
import cognitojwt
tracer = Tracer()
HEADERS = {
"Access-Control-Allow-Origin": os.environ.get("ALLOWED_ORIGIN"),
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "OPTIONS,POST,GET",
"Access-Control-Allow-Credentials": True,
}
class NotFoundException(Exception):
pass
def handle_decimal_type(obj):
"""
json serializer which works with Decimal types returned from DynamoDB.
"""
if isinstance(obj, Decimal):
if float(obj).is_integer():
return int(obj)
else:
return float(obj)
raise TypeError
def generate_ttl(days=1):
"""
Generate epoch timestamp for number days in future
"""
future = datetime.datetime.utcnow() + datetime.timedelta(days=days)
return calendar.timegm(future.utctimetuple())
def get_user_sub(jwt_token):
"""
Validate JWT claims & retrieve user identifier
"""
try:
verified_claims = cognitojwt.decode(
jwt_token, os.environ["AWS_REGION"], os.environ["USERPOOL_ID"]
)
except (cognitojwt.CognitoJWTException, ValueError):
verified_claims = {}
return verified_claims.get("sub")
def get_username(jwt_token):
"""
Validate JWT claims & retrieve user identifier
"""
try:
verified_claims = cognitojwt.decode(
jwt_token, os.environ["AWS_REGION"], os.environ["USERPOOL_ID"]
)
except (cognitojwt.CognitoJWTException, ValueError):
verified_claims = {}
return verified_claims.get("cognito:username")
def get_cart_id(event_headers):
"""
Retrieve cart_id from cookies if it exists, otherwise set and return it
"""
cookie = SimpleCookie()
try:
cookie.load(event_headers["cookie"])
cart_cookie = cookie["cartId"].value
generated = False
except KeyError:
cart_cookie = str(uuid.uuid4())
generated = True
return cart_cookie, generated
def get_headers(cart_id):
"""
Get the headers to add to response data
"""
headers = HEADERS
cookie = SimpleCookie()
cookie["cartId"] = cart_id
cookie["cartId"]["max-age"] = (60 * 60) * 24 # 1 day
cookie["cartId"]["secure"] = True
cookie["cartId"]["httponly"] = True
cookie["cartId"]["samesite"] = "None"
cookie["cartId"]["path"] = "/"
headers["Set-Cookie"] = cookie["cartId"].OutputString()
return headers
- On lines 62-74, you create a new function named get_username that takes a Cognito JSON Web Token, validates it, and retrieves the username from the token.
1
Annotation.username CONTAINS "" AND Annotation.username != "anonymous"
1
Annotation.username = "anonymous"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"category": "sweet",
"createdDate": "2017-11-24T04:01:33 -01:00",
"description": "Fugiat sunt in eu eu occaecat.",
"modifiedDate": "2019-05-19T05:53:56 -02:00",
"name": "half-eaten cake",
"package": {
"height": 337,
"length": 375,
"weight": 336,
"width": 1
},
"pictures": [
"http://placehold.it/32x32"
],
"price": 322,
"productId": "8c843a54-27d7-477c-81b3-c21db12ed1c9",
"tags": [
"officia",
"proident",
"officia",
"commodo",
"nisi"
]
},
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
{
"category": "dairy",
"createdDate": "2018-05-29T11:46:28 -02:00",
"description": "Aliqua officia magna do ipsum laboris anim magna nulla sit labore nulla qui duis.",
"modifiedDate": "2019-05-29T05:33:49 -02:00",
"name": "leftover cheese",
"package": {
"height": 267,
"length": 977,
"weight": 85,
"width": 821
},
"pictures": [
"http://placehold.it/32x32"
],
"price": 163,
"productId": "8d2024c0-6c05-4691-a0ff-dd52959bd1df",
"tags": [
"excepteur",
"ipsum",
"nulla",
"nisi",
"velit"
]
},
1
2
3
4
5
for product in product_list:
product.update(
(k, v.replace("product#", "")) for k, v in product.items() if k == "sk"
)
xray_recorder.end_subsegment()
1
2
3
4
5
for product in product_list:
product["sk"] = product["sk"].replace("product#", "")
if product["sk"] == "8c843a54-27d7-477c-81b3-c21db12ed1c9":
product.remove()
xray_recorder.end_subsegment()
1
2
3
fields @log, @timestamp, @message
| filter @message like "05bf2f42-cd3b-4e4b-8876-f97b022525be" or @requestId = "c2eac3ec-d724-496c-9eb0-a112dc2c459d" or @message like "1-6470a77f-746c4b830dc79084524259da"
| sort @timestamp, @message desc
1
2
3
4
5
for product in product_list:
product["sk"] = product["sk"].replace("product#", "")
if product["sk"] == "8c843a54-27d7-477c-81b3-c21db12ed1c9":
product.remove()
xray_recorder.end_subsegment()
remove()
Python list function on the individual product dictionary. Next, you will fix this error and also improve the exception handling for the function by wrapping the execution in a try / except block:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import json
import os
import boto3
from aws_xray_sdk.core import xray_recorder
from aws_lambda_powertools import Logger, Tracer
from boto3.dynamodb.conditions import Key
from shared import get_cart_id, get_headers, get_user_sub, get_username, handle_decimal_type
logger = Logger()
tracer = Tracer()
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
def lambda_handler(event, context):
"""
List items in shopping cart.
"""
try:
subsegment = xray_recorder.begin_subsegment('annotations')
cart_id, generated = get_cart_id(event["headers"])
if generated:
subsegment.put_annotation('generated_cart', True)
else:
subsegment.put_annotation('generated_cart', False)
# Because this method can be called anonymously, we need to check there's a logged in user
jwt_token = event["headers"].get("Authorization")
if jwt_token:
user_sub = get_user_sub(jwt_token)
username = get_username(jwt_token)
subsegment.put_annotation('username', username)
key_string = f"user#{user_sub}"
logger.structure_logs(append=True, cart_id=f"user#{user_sub}")
else:
subsegment.put_annotation('username', 'anonymous')
key_string = f"cart#{cart_id}"
logger.structure_logs(append=True, cart_id=f"cart#{cart_id}")
# No need to query database if the cart_id was generated rather than passed into the function
if generated:
logger.info("cart ID was generated in this request, not fetching cart from DB")
product_list = []
else:
logger.info("Fetching cart from DB")
response = table.query(
KeyConditionExpression=Key("pk").eq(key_string)
& Key("sk").begins_with("product#"),
ProjectionExpression="sk,quantity,productDetail",
FilterExpression="quantity > :val", # Only return items with more than 0 quantity
ExpressionAttributeValues={":val": 0},
)
product_list = response.get("Items", [])
for index, product in enumerate(product_list):
product["sk"] = product["sk"].replace("product#", "")
if product["sk"] == "8c843a54-27d7-477c-81b3-c21db12ed1c9":
del product_list[index]
json_response = {
"statusCode": 200,
"headers": get_headers(cart_id),
"body": json.dumps({"products": product_list}, default=handle_decimal_type),
}
xray_recorder.end_subsegment()
return json_response
except Exception as e:
logger.error("An unhandled error occurred during execution: {}".format(e))
xray_recorder.end_subsegment()
raise
1
2
3
4
5
for product in product_list:
product["sk"] = product["sk"].replace("product#", "")
if product["sk"] == "8c843a54-27d7-477c-81b3-c21db12ed1c9":
product.remove()
xray_recorder.end_subsegment()

1
Annotation.username CONTAINS "" AND Annotation.username != "anonymous" and responsetime > 3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import json
import os
import json
import os
import boto3
from aws_xray_sdk.core import xray_recorder
from aws_lambda_powertools import Logger, Tracer
from boto3.dynamodb.conditions import Key
from shared import get_cart_id, get_headers, get_user_sub, get_username, handle_decimal_type
from time import sleep
logger = Logger()
tracer = Tracer()
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ["TABLE_NAME"])
def lambda_handler(event, context):
"""
List items in shopping cart.
"""
sleep(3)
try:
subsegment = xray_recorder.begin_subsegment('annotations')
cart_id, generated = get_cart_id(event["headers"])
if generated:
subsegment.put_annotation('generated_cart', True)
else:
subsegment.put_annotation('generated_cart', False)
# Because this method can be called anonymously, we need to check there's a logged in user
jwt_token = event["headers"].get("Authorization")
if jwt_token:
user_sub = get_user_sub(jwt_token)
username = get_username(jwt_token)
subsegment.put_annotation('username', username)
key_string = f"user#{user_sub}"
logger.structure_logs(append=True, cart_id=f"user#{user_sub}")
else:
subsegment.put_annotation('username', 'anonymous')
key_string = f"cart#{cart_id}"
logger.structure_logs(append=True, cart_id=f"cart#{cart_id}")
# No need to query database if the cart_id was generated rather than passed into the function
if generated:
logger.info("cart ID was generated in this request, not fetching cart from DB")
product_list = []
else:
logger.info("Fetching cart from DB")
response = table.query(
KeyConditionExpression=Key("pk").eq(key_string)
& Key("sk").begins_with("product#"),
ProjectionExpression="sk,quantity,productDetail",
FilterExpression="quantity > :val", # Only return items with more than 0 quantity
ExpressionAttributeValues={":val": 0},
)
product_list = response.get("Items", [])
for index, product in enumerate(product_list):
product["sk"] = product["sk"].replace("product#", "")
if product["sk"] == "8c843a54-27d7-477c-81b3-c21db12ed1c9":
del product_list[index]
json_response = {
"statusCode": 200,
"headers": get_headers(cart_id),
"body": json.dumps({"products": product_list}, default=handle_decimal_type),
}
xray_recorder.end_subsegment()
return json_response
except Exception as e:
logger.error("An unhandled error occurred during execution: {}".format(e))
xray_recorder.end_subsegment()
raise
- Delete the Anonymous, RegisteredUsers, and SlowRegisteredUsers AWS X-Ray groups you created in Step 4.
- Delete the SlowRegisteredUsersNotification SNS topic you created in Step 5.
- Delete the SlowRegisteredUsersAlarm CloudWatch Alarm you created in Step 5.
Any opinions in this post are those of the individual author and may not reflect the opinions of AWS.