Our daily lives, whether we’re gaming, streaming or working from home, revolve around the internet and all the digital services that it provides. A pillar of this connection is the reliability of the download and upload speeds that your ISP can deliver. When you sign up for a service that promises certain speeds, you want to validate that you get what you pay for. This need inspired me to create SpeedtestTracker: a system that performs daily speed tests, stores the result in a database and even sends out an alert via a Telegram bot. This blog post will walk you through the steps I took to bring this idea to life.
1. Designing the system
SpeedtestTracker is set up as a two part system:
- the client for running speedtests, extracting and saving the results
- the server for processing, storing and sending alerts of the data
The Raspberry Pi, as the title already reveals, will act as the client and the server will be made of an AWS Lambda function. To streamline the development, I consolidated both the client-side and server-side components into a single TypeScript project. This approach enabled interface sharing, simplifying the communication between the two components.
The following dependencies played an important role in the implementation:
- Node.js v22
- Axios for sending data to the server-side
- esbuild for bunding both the client and server separately
- dotenv for loading environment variables
- node-cron for scheduling daily speedtests on the client
- Pino for structured logging
- Telegraf as a library for interacting with Telegram bots
- AJV for validating JSON responses from the speedtest cli
If this sounds like something that you would like to try out or improve, check out the souce code on GitHub.
2. Hardware setup with the Raspberry Pi as the client
The backbone of the project starts with the Raspberry Pi. A very small, yet powerful and energy efficient computer that can run speedtests according to a schedule. Running frequent speedtests the whole day could congest your network, so I opted for a daily schedule instead. For my use-case, I don’t need to see deviations in my speed throughout the day. Having my Pi run a speedtest once a day fits my needs to collect data for statistical analysis over time.
2.1 Speedtest-cli
The Pi is going to run a shell script that executes the Speedtest-cli tool to capture the Internet speed results. Here’s the command it uses:
speedtest --format=json
To automate the process, I wrote a TypeScript function that executes the shell command with error handling and parsing of the JSON result. Here’s the implementation:
export function runSpeedtest(): Promise<any> {
const command = "speedtest --format=json";
return new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
reject(`Error executing command: ${error.message}`);
}
if (stderr) {
reject(`Standard Error: ${stderr}`);
}
try {
const result = JSON.parse(stdout);
resolve(result);
} catch (parseError) {
reject(`Failed to parse JSON output: ${parseError}`);
}
});
});
}
2.2 Sending data to the endpoint
After the JSON result is retrieved, we can send this data to an endpoint. This endpoint URL is protected by an API key and triggers an AWS Lambda when it’s called. I’m using the address in the input argument as a location identifier. Here’s the function for sending the data via a POST request:
export const saveSpeedTestResult = async (
speedtestResult: SpeedtestResultDto,
url: string,
apiKey: string,
address: string,
) => {
const headers = {
"Content-Type": "application/json",
"x-api-key": apiKey,
};
const payload: SpeedtestTrackerPayload = {
pk: address,
result: speedtestResult,
};
const response = await axios.post(url, payload, { headers });
};
2.3 Scheduling a cron job
With these methods in place, we can schedule the speedtest to run once a day at a specific time with node-cron. This is what the cron job looks like:
cron.schedule("0 13 * * *", async (result) => {
const url = process.env.URL as string;
const apiKey = process.env.API_KEY as string;
const address = process.env.ADDRESS as string;
try {
const speedtestResult = await runSpeedtest();
await saveSpeedTestResult(speedtestResult, url, apiKey, address);
} catch (error) {
logger.error({ error }, "Error in running the cron job");
}
});
2.4 Managing the node process with PM2
When running this script on the Raspberry Pi we want to keep the cron job alive. If you start the node process for the cron job via an SSH session, the process will terminate when you terminate the SSH session. This is where PM2 comes in. PM2 is a process manager that can run node processes in the background indefinitely.
While PM2 can run TypeScript directly, it’s not recommended in production due to inefficiency. To overcome this, we will first transpile TypeScript into JavaScript with esbuild:
esbuild lib/speedtest-cli/index.ts --bundle --platform=node --sourcemap --target=node22 --legal-comments=none --outfile=build/speedtest-cli/index.js
Run the transpiled JavaScript with PM2:
pm2 start build/speedtest-cli/index.js
For convenience, these commands can be added to the package.json. As long as your Raspberry Pi remains powered on, this node process will run in the background indefinitely.
3. AWS Lambda as the server
On the server side, I use an AWS Lambda function to process and store the results. For the infrastructure, project setup and the deployment, I rely on AWS CDK.
3.1 AWS CDK and project setup
The AWS Cloud Development Kit, or AWS CDK, allows me to define the cloud infrastructure using a familiar programming language instead of managing a big YAML or JSON file that is required by AWS CloudFormation. In my situation I’m using TypeScript to define my resources.
To initialize a CDK project, I ran the following command in my SpeedtestTracker project:
cdk init app --language typescript
Next, I installed the esbuild
dependency that will be used by the CDK to compile my TypeScript code:
npm install --save-dev esbuild
Inside the lib
directory, CDK generates a file called speedtest-tracker-stack.ts
. This acts as the scaffolding where you can define your resources.
3.2 Adding an AWS Lambda resource
For my Lambda resource I added the following code:
const fn = new NodejsFunction(this, "SaveSpeedtestFunction", {
entry: path.resolve(__dirname, "lambda-handler/index.ts"),
runtime: lambda.Runtime.NODEJS_LATEST,
handler: "handler",
bundling: {
sourceMap: true,
},
environment: {
NODE_OPTIONS: "--enable-source-maps",
BOT_TOKEN: process.env.BOT_TOKEN!,
CHAT_ID: process.env.CHAT_ID!,
},
});
The lambda handler according to this snippet will be located in the lambda-handler directory and I’ve included the required environment variables that should be included for the lambda to run correctly.
3.3 Adding a DynamoDB resource for data storage
To store the results, I’ve set up a DynamoDB table. The following code snippet creates a table with the partitionKey and sortKey and grants read/write rights to the Lambda function:
const table = new dynamodb.TableV2(this, "Table", {
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
sortKey: { name: "epochTime", type: dynamodb.AttributeType.NUMBER },
tableName: "speedtest-tracker",
});
table.grantReadWriteData(fn);
3.4 Creating an securing the API Gateway
An API Gateway serves as the interface between the client and the server. To secure the API, I added an API key and linked it to a usage plan. The usage plan limits request rates, ensuring that the API stays performant. Here’s the code:
const api = new apigw.LambdaRestApi(this, `ApiGwEndpoint`, {
handler: fn,
restApiName: `SpeedtestTrackerApi`,
deployOptions: {
stageName: "prod",
},
defaultMethodOptions: {
apiKeyRequired: true,
},
});
const plan = api.addUsagePlan("UsagePlan", {
name: "SpeedtestTrackerUsagePlan",
throttle: {
rateLimit: 10,
burstLimit: 2,
},
});
const apiKey = api.addApiKey("ApiKey", {
apiKeyName: "SpeedtestTrackerApiKey",
});
plan.addApiKey(apiKey);
plan.addApiStage({
stage: api.deploymentStage,
});
3.5 Deploy the defined resources
With all the resources defined, they can be synthesized and deployed with the following commands:
cdk synth
cdk deploy
3.6 Lambda handler
The lambda handler will act as the server-side part, that will process the incoming speedtest result from the Raspberry Pi, validates it using the AJV schema validation and then stores it in DynamoDB. At the end we will also send a success/failure message via Telegram using Telegraf.
The core lambda handler looks like this:
export const handler: APIGatewayProxyHandler = async (
event: APIGatewayProxyEvent,
context: Context,
): Promise<APIGatewayProxyResult> => {
try {
withRequest(event, context);
logger.info({ data: event }, "Received event");
const payload = extractPayload(event);
const { pk: primaryKey, result: speedtestResultDto } = payload;
const speedtestResult = getSpeedtestResult(speedtestResultDto);
const telegramMessage = constructSuccessMessage(
primaryKey,
speedtestResult,
);
const putResponse = await putItem(primaryKey, speedtestResult);
await sendTelegramMessage(telegramMessage);
return successResponse(JSON.stringify(putResponse, null, 2));
} catch (error) {
logger.error("Error in the lambda handler");
let statusCode: number;
let message: string;
switch (true) {
case error instanceof SpeedtestTrackerValidationError:
statusCode = 400;
message = (error as SpeedtestTrackerValidationError).message;
break;
case error instanceof SyntaxError:
statusCode = 400;
message = (error as SyntaxError).message;
break;
default:
statusCode = 500;
message = `Unknown error occurred: ${error}`;
break;
}
const stringifiedMessage = JSON.stringify(message, null, 2);
const telegramMessage = constructFailureMessage(stringifiedMessage);
await sendTelegramMessage(telegramMessage);
return errorResponse(statusCode, stringifiedMessage);
}
};
3.7 JSON validation
In the lambda handler, we’re using an extractPayload function that parses and validates the incoming JSON against a predefined schema. If there are missing fields in the payload, an error SpeedtestTrackerValidationError
will be thrown to prevent incorrect data from being stored into the DynamoDB.
extractPayload = (event: APIGatewayProxyEvent) => {
if (event.body === null) {
throw new SpeedtestTrackerValidationError("Payload may not be empty");
}
const parsedBody = JSON.parse(event.body);
const isValid = validate(parsedBody)
if (!isValid) {
throw new SpeedtestTrackerValidationError(`JSON validation failed with error: ${JSON.stringify(validate.errors)}`);
}
return parsedBody as SpeedtestTrackerPayload;
}
The setup for the validate function can be found over here in [the project].(https://github.com/duncanlew/SpeedtestTracker/blob/main/lib/lambda-handler/schema.ts)
3.8 Save result in DynamoDB
The lambda handler will persist the speedtest result in DynamoDB using the putItem
utility function. It will structure the speedtest result for querying and analysis. Each entry requires a primary key (pk) and a sort key. The pk will store the address of the home for which I’m tracking the speedtest results. The sort key will store the epoch time in seconds. Using this composite key of pk and sort key, you will be able query for all the speedtest results of a range.
export const putItem = async (
primaryKey: string,
speedtestResult: SpeedtestResult,
) => {
const now = new Date();
const command = new PutCommand({
TableName: TABLE_NAME,
Item: {
pk: primaryKey,
epochTime: toEpochSeconds(now.getTime()),
date: now.toISOString(),
payload: speedtestResult,
downloadMbps: speedtestResult.downloadMbps,
uploadMbps: speedtestResult.uploadMbps,
pingMs: speedtestResult.pingMs,
},
});
return await docClient.send(command);
};
3.9 Send Telegram message
For the telegram integration, I made use of the Telegraf dependency which requires two variables:
CHAT_ID
BOT_TOKEN
To get the BOT_TOKEN
, you will first have to create a specialized chatbot via the Telegram app. During the creation of your chatbot, a token will unique and private token will be generated. The CHAT_ID
is the specific chat session for which you want the chatbot to send a message.
These env variables will be handled in the .env file which can be loaded with the dotenv dependency.
import { Telegraf } from "telegraf";
import * as dotenv from "dotenv";
const chatId = process.env.CHAT_ID!;
const botToken = process.env.BOT_TOKEN!;
const bot = new Telegraf(botToken);
export const sendTelegramMessage = async (message: string): Promise<void> => {
await bot.telegram.sendMessage(chatId, message);
};
4. Takeaway
Building a SpeedtestTracker with a Raspberry Pi demonstrates how to turn a simple IoT device into a monitoring tool for your internet performance. We also leveraged AWS native tools like AWS CDK, AWS Lambda, and DynamoDB to process, store, and send alerts for the daily speedtest results. This project serves as an important learning tool to apply your knowledge of how to develop and utilize both cloud and hardware resources to achieve your goal. There are many small but valuable challenges that you’ll encounter like setting up deployments, validating JSON for data storage and configuring Telegram alerts. No matter whether you’re looking into learning about network monitoring or wanting to expand your skills in serverless architecture, this project is an ideal starting point. Happy coding! 🚀
SpeedtestTracker
Built with Raspberry Pi and AWS Lambda
The accompanying blog post for this project can be found on Medium and dev.to.
If the content was helpful, feel free to support me here:
![Buy Me A Coffee](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcdn.buymeacoffee.com%2Fbuttons%2Fdefault-orange.png)
Top comments (0)