How I Built an AWS App Without Spending a Penny — Pinpoint

Abhishek Chaudhuri
12 min readAug 24, 2024

--

AWS logo with dollar sign crossed out

This is part 8 of a now 8-part series. See the previous parts where we built the frontend, backend, pipeline, and authentication.

In the previous parts, we built a full-stack web app that utilizes many AWS services. This is a great project to showcase on your resume, especially if you’re trying to get into cloud development and don’t have many years of experience. While listing all the AWS services used may be good for ATS, listing metrics from the app is a great way to show impact.

Data collecting and analytics are two key ways of showcasing an app’s metrics. Many companies dedicate just as much, if not more, resources toward collecting unstructured data and transforming it into pretty charts and diagrams that executives can use to determine the success of their projects.

Up to this point, we’ve been relying on CloudWatch to collect metrics and create dashboards for various services. And that’s not to say it hasn’t been useful. Most of the services we’ve been using have a dashboard automatically generated by AWS at no cost and they show some cool metrics. The CloudFront ones, in particular, give great insight into our web app and server, such as the number of requests, the most popular S3 objects, and what platforms viewers are visiting the site from (mobile, desktop, etc.) (Granted, most of the traffic comes from the Route 53 health checks created in part 6.)

However, CloudWatch is primarily designed as an observability tool for OS-level metrics, not necessarily business-level metrics. For example, if you’re interested in learning which APIs are the slowest and how CPU/memory they use, CloudWatch and X-Ray would be good choices to measure Lambda performance. But if you’re interested in learning how many users prefer light or dark mode in your app, that’s a little more difficult to track in CloudWatch. You would need to create a custom metric and make an API call from the web app to publish that metric to CloudWatch. You could call PutMetricData, but you can only create 10 custom metrics per month within the free tier, and then it’s 30₵ for each additional metric. Each metric in CloudWatch can be associated with one or more dimensions (or key-value pairs) to provide additional context. Each metric must also have a unit, mostly limited to size (bytes), time (seconds), or count/percentage. So in my case, I could create a click metric with a count of 1 and a dimension of isDarkMode=true/false. Even though the metric name is the same, this counts as 2 metrics instead of 1 due to each combination of dimensions. With just one “metric”, we’re already using 20% of the free tier.

I initially brushed off collecting analytics from the front end due to CloudWatch’s limitations. It’s one of AWS’s most popular services, so I didn’t think to look elsewhere to collect business metrics. However one day, I revisited this problem and took another look at how Amplify handles its analytics feature. Amplify uses Pinpoint to record events from a web or mobile app. I glanced at Pinpoint’s pricing and saw that its free tier only lasts for the first 12 months. It’s mainly promoted as a tool to send targeted messages to users based on certain campaigns or journeys they participate in. These messages could take the form of an email, text message, or push notification. It seemed more catered for mobile apps, especially regarding push notifications. In fact, in the same Architecting Serverless Apps course I talked about at the beginning of this series, they showed an architecture diagram for mobile apps that includes Pinpoint as one of its services:

AWS architecture diagram for a sample full-stack mobile app
From Architecting Serverless Applications

So despite Pinpoint seeming like an underutilized service, AWS thought it was important enough to include as part of a standard mobile app architecture. But I was still deterred from its pricing. Each device you send messages to counts as a separate endpoint, and you can only have 8–9 endpoints once the 12-month free trial ends before you’re charged a penny. However, for collecting business metrics, we’d be primarily interested in Pinpoint’s event feature. And that has a much more generous pricing model by comparison. Even after the free trial ends, you can still send 10K events before being charged a penny. Compared to CloudWatch, it offers more flexibility regarding the types of events you can send, so it seemed like a good fit for my web app.

To send an event using Pinpoint, we need to call PutEvents. Since the payload is complex and to avoid managing AWS credentials on the client side, we can create a Lambda function to handle sending events, similar to the diagram above. We can reuse the same API Gateway from part 4 and add a new endpoint to call the new Lambda function. Then we can call this API from the web app and pass various events for analysis.

First, we need to update the GitHub Actions role to allow access to Pinpoint (see GitHubRemainingPolicy in part 5). Surprisingly, there isn’t a built-in policy for Pinpoint. There is AmazonMobileAnalyticsFullAccess, but that uses the old name for Pinpoint — Amazon Mobile Analytics — for its actions (mobileanalytics:* instead of mobiletargeting:*). (I guess even Amazon forgot this service existed!) So, we’ll need to add this policy ourselves:

- Sid: AmazonPinpointFullAccess
Effect: Allow
Action:
- "mobiletargeting:*"
Resource: "*"

Next, we’ll add an event path to the OpenAPI spec:

# under paths:
/event:
post:
summary: Publish an event to Pinpoint
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/event"
responses:
"202":
description: Successfully published the event
content:
application/json:
schema:
$ref: "#/components/schemas/event-success"
"400":
description: The event payload isn't valid
content:
application/json:
schema:
$ref: "#/components/schemas/error"
x-amazon-apigateway-integration:
$ref: "#/components/x-amazon-apigateway-integrations/event-lambda"
# under components:
# under schemas:
event:
type: object
properties:
name:
type: string
description: The event name
properties:
type: object
# Matches Amplify's iOS/Android logic: https://stackoverflow.com/a/68231896
description: >-
Key-value pairs that describe the event.
If the value is a string or boolean, it's added as an attribute.
If the value is a number, it's added as a metric.
additionalProperties:
anyOf:
- type: string
- type: number
- type: boolean
event-success:
type: string
description: The success message
# under x-amazon-apigateway-integrations
event-lambda:
type: aws_proxy
httpMethod: POST
# !Sub "arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AnalyticsLambdaFunction.Arn}/invocations"
uri: arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:701157632481:function:AWS-Shop-Store-Service-AnalyticsLambdaFunction-GunAoB5AH917/invocations
passthroughBehavior: when_no_templates
payloadFormatVersion: "2.0"
timeoutInMillis: 3000
# under x-amazon-apigateway-cors:
# under allowMethods:
- PUT
- PATCH
- POST
- DELETE

We’re going to create a POST request to /event to send events to Pinpoint. The body contains the name of the event and all its properties, which can be an arbitrary set of key-value pairs. This gives the UI the flexibility to publish events based on business requirements, while the Lambda function takes care of translating those events to a valid PutEvents call.

One important change we need to make is to allow CORS to accept POST requests. Previously, we only allowed read-only HTTP methods like GET or HEAD. But now that we’re passing a request body, the preflight request must succeed. Once these changes are deployed, you can test the preflight request in Postman by sending an OPTIONS request to the CloudFront & API Gateway endpoints with the following headers:

  • Origin: the origin of the UI that will be calling the backend (either localhost for dev or the CloudFront endpoint for prod)
  • Access-Control-Request-Method: POST
  • Access-Control-Request-Headers: Content-Type

Then check the response headers to make sure Access-Control-Allow-Origin/Methods/Headers/Credentials are all present.

Now for the Lambda function:

import boto3
from datetime import datetime
import json
import logging
import os

pinpoint = boto3.client("pinpoint")

# Enable detailed logging
LOG = logging.getLogger()
LOG.setLevel(logging.INFO)


def handler(event, context):
# Event types: https://docs.aws.amazon.com/lambda/latest/dg/lambda-services.html
LOG.info(f"{event=}")

body = ""
status_code = 200
headers = {
"Content-Type": "application/json",
}

try:
route_key = event["routeKey"]
request_body_str = event["body"]
request_body = json.loads(request_body_str)

if route_key == "POST /event":
is_valid_event, error_message = validate_event_object(request_body)

if is_valid_event:
status_code, body = publish_event(request_body)
else:
raise Exception(f"Invalid request body: {error_message}")
else:
raise Exception(f'Unsupported route: "{route_key}"')
except Exception as e:
# Catch-all for errors
status_code = 400
body = str(e)
finally:
# Don't stringify empty bodies (but do so for empty arrays)
if body != "":
body = json.dumps(body)

response = {"statusCode": status_code, "headers": headers, "body": body}
LOG.info(f"{response=}")
return response


def publish_event(event, pinpoint=pinpoint):
app_id = os.environ.get("PinpointAppId", "")
timestamp = datetime.now().isoformat()
# anonymous = generic endpoint ID that encompases all users
endpoint_id = "anonymous"
event_id = f"event-{timestamp}"
attributes, metrics = categorize_event_properties(event["properties"])

events_request = {
"BatchItem": {
f"{endpoint_id}": {
"Endpoint": {},
"Events": {
f"{event_id}": {
"Attributes": attributes,
"EventType": event["name"],
"Metrics": metrics,
"Timestamp": timestamp,
}
},
}
}
}

response = pinpoint.put_events(
ApplicationId=app_id,
EventsRequest=events_request,
)
LOG.info(f"Pinpoint response: {response}")

"""
Sample response:
{
"ResponseMetadata": { ... },
"EventsResponse": {
"Results": {
"anonymous": {
"EndpointItemResponse": {"Message": "Accepted", "StatusCode": 202},
"EventsItemResponse": {
"event-2024-08-11T20:26:22.250176": {
"Message": "Accepted",
"StatusCode": 202,
}
},
}
}
},
}
"""
events_response = response["EventsResponse"]["Results"][endpoint_id][
"EventsItemResponse"
][event_id]
# Status code will either be 202 (success) or 400 (failure)
return events_response["StatusCode"], events_response["Message"]


def validate_event_object(event):
# Check that the request body is formatted correctly
if "name" not in event:
return False, 'Missing "name" key'
elif not isinstance(event["name"], str):
return False, '"name" must be a string'
elif "properties" not in event:
return False, 'Missing "properties" key'
elif not isinstance(event["properties"], dict):
return False, '"properties" must be an object'

return True, None


def categorize_event_properties(properties):
# If the event value is a string or boolean, add it to attributes
# If the event value is a number, add it to metrics
# Matches Amplify's iOS/Android logic: https://stackoverflow.com/a/68231896
attributes = {}
metrics = {}

for key, value in properties.items():
# Check bool first since it's also an instance of int
if isinstance(value, (str, bool)):
attributes[key] = str(value)
elif isinstance(value, (int, float)):
metrics[key] = value
# Ignore all other types

return attributes, metrics

There’s a lot to unpack here. Like the store API in part 4, we use the routeKey to check that the user made a POST /event call and ignore all other API calls. As good practice, we validate the body to make sure it’s formatted properly.

The PutEvents API accepts an application ID and an event request. The application ID will be passed from the SAM template as an environment variable, similar to how we passed the SNS topic in IAM Old in part 3. The event request consists of an endpoint and an event. We don’t want to create too many endpoints due to pricing. Since we can report these events anonymously, we can create an endpoint ID called “anonymous” for all these events and keep the endpoint section blank. The events also need IDs. Since these won’t be displayed on the graphs, we can set the ID based on the current timestamp to ensure each event is unique. We will need the timestamp anyway for one of the event parameters. The EventType can be set as the event name from the request body since this will be used to filter specific events.

Finally, we need to pass attributes and metrics. Attributes are strings, while metrics are numbers that measure each event. Instead of asking the client to remember that, we can delegate that task to the Lambda function. This is how Amplify handles publishing events in their iOS and Android SDKs. It makes the code less error-prone and gives more flexibility back to the client.

PutEvents will respond with either 202 for success or 400 for failure. 202 is slightly different from 200. This means the request was accepted, but it will take Pinpoint some time to publish the event in the console (usually up to 30 minutes). In the web app, we can send these events asynchronously without blocking the user and we don’t need to handle failures other than logging them.

Finally, let’s update the SAM template to accommodate the new analytics service:

# Supported global properties: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification-template-anatomy-globals.html#sam-specification-template-anatomy-globals-supported-resources-and-properties
Globals:
Function:
# Zip files run faster than container images (setting PackageType causes false drift)
CodeUri: src/
Runtime: python3.11 # see https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html
Architectures:
- arm64
# Can use function URLs if API Gateway isn't necessary
# 128 MB of memory allocated by default
# Automatically update the runtime version
# Timeout after 3 seconds
Tracing: Active
DeadLetterQueue:
TargetArn: !GetAtt LambdaDLQ.Arn # automatically applies permissions to the execution role
Type: SQS
# under AllowedMethods: for the CloudFront distribution
- PUT
- PATCH
- POST
- DELETE
# under Resources:
LambdaFunction:
# Creates AWS::Lambda::Permission/Function, AWS::IAM::Role
Type: AWS::Serverless::Function
Metadata:
guard:
SuppressedRules:
# Reliability suppressions
- LAMBDA_CONCURRENCY_CHECK # save costs
- LAMBDA_INSIDE_VPC # no VPC created (also security check)
# Security suppressions
- IAM_NO_INLINE_POLICY_CHECK # SAM policy templates become inline policies
Properties:
Handler: app.handler
Description: Query the AWS Services table
Events:
HttpApiEvent:
Type: HttpApi
Properties:
ApiId: !Ref HttpApi
Policies:
# SAM policy templates:
# https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-policy-templates.html
- DynamoDBReadPolicy:
TableName: !Ref AWSServiceTable
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- dynamodb:PartiQLSelect
Resource:
- !Sub
- "arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tableName}"
- tableName: !Ref AWSServiceTable
- !Sub
- "arn:${AWS::Partition}:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${tableName}/index/*"
- tableName: !Ref AWSServiceTable
AnalyticsLambdaFunction:
Type: AWS::Serverless::Function
Metadata:
guard:
SuppressedRules:
# Reliability suppressions
- LAMBDA_CONCURRENCY_CHECK # save costs
- LAMBDA_INSIDE_VPC # no VPC created (also security check)
# Security suppressions
- IAM_NO_INLINE_POLICY_CHECK # SAM policy templates become inline policies
Properties:
Handler: analytics.handler
Description: Publish events to Pinpoint
Events:
HttpApiEvent:
Type: HttpApi
Properties:
ApiId: !Ref HttpApi
Policies:
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- mobiletargeting:PutEvents
Resource:
- !Sub
- "arn:${AWS::Partition}:mobiletargeting:${AWS::Region}:${AWS::AccountId}:apps/${projectId}/events"
- projectId: !Ref PinpointApp
Environment:
Variables:
PinpointAppId: !Ref PinpointApp
# Pinpoint
PinpointApp:
Type: AWS::Pinpoint::App
Properties:
Name: AWS-Shop

By far the easiest thing to provision is the Pinpoint app. All we need is the name, that’s it! Like with API Gateway, we must allow CloudFront to accept all other HTTP methods to let the POST request through.

Since we’re creating another Lambda function, this is a good opportunity to showcase another feature exclusive to SAM templates: globals. Globals allow you to define common properties that can be shared across the entire template. You can check the link in the comment above for all the supported properties. (Note that you can still define these properties at the resource level. They will override the global property.) In our case, both Lambda functions will be located in the src directory, run the same Python version, use ARM architecture, use X-Ray tracing, and use the same DLQ. We can remove these properties from the store Lambda function created in part 4 while keeping the ones unique to the function. For the analytics function, even though the trigger is the same (the same API Gateway), the destination will be Pinpoint. So, we need to create an IAM policy to allow Lambda to call PutEvents. This is also where we set the environment variable based on the Pinpoint app ID.

As a side note, you can get more info about the anonymous endpoint by calling GetEndpoint. If you don’t remember the endpoint name, you will need to create a job to export the data to S3 according to this guide.

With all that in place, we can now deploy our changes. If all goes well, you can start calling the event API from your website. After some time, you should be able to see event activity in the console.

sample event charts
Sample event charts

Now the default charts in Pinpoint are very basic. They only show the event counts per day. But you can filter by attributes and metrics. You need to enable filters in the console and they will be deactivated if unused for 90 days. For example, to track dark mode usage, I created an event called app-bar (since the controls are at the top of the screen) with an attribute called darkMode and values of either true or false, depending on what the user toggled.

{
"name": "app-bar",
"properties": {
"darkMode": true
}
}
event filters
Event filters
event charts when dark mode is true
Event charts when dark mode is true

If you want a more detailed analysis, you will need to enable event streams to export data to S3 using Kinesis Data Firehose (now called Amazon Data Firehose). From there, you can query the data using Athena, manage the data using Redshift, or visualize the data using QuickSight. All those options are out of scope for our low-budget app, but it is the price you pay for frugality (or rather the price you don’t pay). But when the workflow becomes Client → CloudFront → API Gateway → Lambda → Pinpoint → Kinesis → S3 → QuickSight for something akin to Google Analytics, you might want to start questioning whether it was all worth it in the end. (And that’s not even including things like Route 53, WAF, or VPCs!)

And that about covers it for Pinpoint events. It tends to get overshadowed by its more popular counterpart: CloudWatch. But it does offer more flexibility and a generous price point if you’re merely interested in collecting event data from your app. However, its analytical capabilities are limited.

Thanks for reading! As usual, you can check out the GitHub repo at https://github.com/Abhiek187/aws-shop if you want to browse the full source code. Hopefully, you learned something new, and don’t forget to check out the previous parts linked at the top if you want to learn more about building AWS for cheap.

--

--