I built a thing - price monitor using AWS CDK, Lambda, DynamoDB and SES
I wanted to buy a PlayStation 5 but it was out of stock all over the place, so I spent a short amount of time building a price monitor using AWS CDK, Lambda, DynamoDB and SES. The implementation turned out to be simple enough, so I decided to share the core parts with you. This article assumes that you have at least some basic understanding of the AWS platform and CDK framework.
Price monitor architecture #
Everything starts from a scheduled EnventBridge rule (ScheduledEvent
) that triggers the Lambda function (PriceCheckLambda
) every so often (in my case, 15 minutes). This function reads current product prices stored in DynamoDB (PriceTable
), compares with a current price online and updates DB accordingly. Updates in DynamoDB trigger a second Lambda (NotificationLambda
) that sends an email to my mailbox. Done!
I deliberately stripped down the noise and kept only the core parts. You can find my full implementation of the price monitor on my GitHub.
CDK stack #
To provision architecture, I used AWS CDK. On one of the previous projects, I used pure CloudFormation templates, and I don’t miss these times since the day I embraced CDK.
// stack.ts
import * as path from "path";
import * as cdk from "@aws-cdk/core";
import * as events from "@aws-cdk/aws-events";
import * as targets from "@aws-cdk/aws-events-targets";
import * as lambdaNodejs from "@aws-cdk/aws-lambda-nodejs";
import * as lambda from "@aws-cdk/aws-lambda";
import * as dynamodb from "@aws-cdk/aws-dynamodb";
import * as iam from "@aws-cdk/aws-iam";
import * as awsLambdaEventSources from "@aws-cdk/aws-lambda-event-sources";
export class Stack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string) {
super(scope, id);
const dynamoDbTable = new dynamodb.Table(this, "PriceTable", {
partitionKey: {
name: "id",
type: dynamodb.AttributeType.STRING,
},
stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
});
const lambdaPriceCheck = new lambdaNodejs.NodejsFunction(
this,
"PriceCheckLambda",
{
entry: "./price-check.ts",
environment: {
TABLE_NAME: dynamoDbTable.tableName,
},
}
);
const lambdaNotification = new lambdaNodejs.NodejsFunction(
this,
"NotificationLambda",
{
entry: "./price-notification.ts",
}
);
dynamoDbTable.grantReadWriteData(lambdaPriceCheck);
lambdaNotification.addEventSource(
new awsLambdaEventSources.DynamoEventSource(dynamoDbTable, {
startingPosition: lambda.StartingPosition.TRIM_HORIZON,
batchSize: 1,
})
);
lambdaNotification.addToRolePolicy(
new iam.PolicyStatement({
actions: ["ses:SendEmail"],
resources: ["*"],
})
);
new events.Rule(this, "ScheduledEvent", {
schedule: events.Schedule.rate(cdk.Duration.minutes(15)),
targets: [new targets.LambdaFunction(lambdaPriceCheck)],
});
}
}
Price check Lambda #
This function is responsible for comparing prices from the database to current prices online. I used cheerio for the web scraping part, but most likely, I will replace it with Puppeteer.
// price-check.ts
import fetch from "node-fetch";
import cheerio from "cheerio";
import {
DynamoDBClient,
ScanCommand,
PutItemCommand,
} from "@aws-sdk/client-dynamodb";
import { ScheduledHandler } from "aws-lambda";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
interface PriceRow {
selector: string;
id: string;
url: string;
email: string;
item: string;
price: string;
}
const { AWS_REGION: region, TABLE_NAME: TableName } = process.env;
const dbClient = new DynamoDBClient({ region });
const handler: ScheduledHandler = async (event) => {
try {
const { Items, Count } = await dbClient.send(
new ScanCommand({
TableName,
})
);
if (!Count) {
return;
}
const itmesUnmarshall = Items?.map((i) => unmarshall(i)) as PriceRow[];
const newPrices = await Promise.all(
itmesUnmarshall.map(({ url }) =>
fetch(url).then((response) => response.text())
)
);
const diff = itmesUnmarshall.reduce((acc, item, index) => {
const $ = cheerio.load(newPrices[index]);
const price = $(item.selector).text();
if (price === item.price) {
return acc;
}
return [...acc, { ...item, price }];
}, [] as PriceRow[]);
if (diff.length) {
const updateCommands = diff.map((item) =>
dbClient.send(
new PutItemCommand({
TableName,
Item: marshall(item),
})
)
);
await Promise.all(updateCommands);
}
return;
} catch (error) {
console.error(error);
throw new Error("Uuuups!");
}
};
export { handler };
Notification Lambda #
This function is responsible for parsing the DynamoDB stream that contains old and new prices and sending a notification email. I used SES to handle email communication, but you know — you do you.
// price-notification.ts
import { unmarshall } from "@aws-sdk/util-dynamodb";
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
import { DynamoDBStreamHandler } from "aws-lambda";
const { AWS_REGION: region } = process.env;
const sesClient = new SESClient({ region });
const handler: DynamoDBStreamHandler = async (event) => {
const record = event.Records[0];
const {
// @ts-ignore
dynamodb: { NewImage, OldImage },
eventName,
} = record;
if (eventName !== "MODIFY") {
return;
}
const unmarshalledNewImage = unmarshall(NewImage);
const unmarshalledOldImage = unmarshall(OldImage);
const { price, item, email } = unmarshalledNewImage;
try {
await sesClient.send(
new SendEmailCommand({
Source: email,
Destination: {
ToAddresses: [email],
},
Message: {
Body: {
Html: {
Charset: "UTF-8",
Data: `Old price: ${unmarshalledOldImage.price}, new price: ${price}`,
},
},
Subject: {
Charset: "UTF-8",
Data: `💰 Price alert - ${item}`,
},
},
})
);
return;
} catch (error) {
console.error(error);
throw new Error("Uuuups!");
}
};
export { handler };
To summarise #
Creating this service was very enjoyable — good Philips Hue deals on Amazon and my new discounted pair of kicks I like even more. I pay for this service absolutely nothing, thanks to generous AWS free tier. By the way, I don’t want to buy PS5 anymore. Hopefully, you found it helpful 🤪