Skip to content

Commit 09333a5

Browse files
committed
add scheduled validation lambda for ab testing expiry dates
1 parent 94786fd commit 09333a5

File tree

17 files changed

+1407
-20
lines changed

17 files changed

+1407
-20
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: 🧪 AB testing expiry checker
2+
3+
permissions:
4+
contents: read
5+
6+
on:
7+
schedule:
8+
# runs at 5am daily
9+
- cron: '0 5 * * *'
10+
11+
jobs:
12+
check-expiry:
13+
runs-on: ubuntu-latest
14+
name: Check test expiry
15+
defaults:
16+
run:
17+
working-directory: ab-testing/config
18+
env:
19+
FASTLY_AB_TESTING_CONFIG: ${{ secrets.FASTLY_PROD_AB_TESTING_CONFIG }}
20+
FASTLY_API_TOKEN: ${{ secrets.FASTLY_PROD_API_TOKEN }}
21+
22+
steps:
23+
- uses: actions/checkout@v5
24+
25+
- name: Set up Node environment
26+
uses: ./.github/actions/setup-node-env
27+
28+
- name: Validate
29+
run: pnpm validate
30+
31+
- name: Build
32+
run: pnpm build
33+
34+
- uses: actions/upload-artifact@v5
35+
with:
36+
name: ab-testing-build
37+
path: ab-testing/config/dist
38+
39+
lambda-ci:
40+
name: Lambda CI
41+
runs-on: ubuntu-latest
42+
defaults:
43+
run:
44+
working-directory: ab-testing/deploy-lambda
45+
permissions:
46+
contents: read
47+
steps:
48+
- uses: actions/checkout@v5
49+
50+
- name: Set up Node environment
51+
uses: ./.github/actions/setup-node-env
52+
53+
- name: Build Lambda
54+
run: pnpm build
55+
56+
- name: Zip app artifact
57+
run: |
58+
cd dist
59+
zip -r lambda.zip .
60+
zip -j lambda.zip ../package.json
61+
62+
- name: Save build
63+
uses: actions/upload-artifact@v5
64+
with:
65+
name: ab-testing-lambda-build
66+
path: ab-testing/deploy-lambda/dist/lambda.zip

.github/workflows/ab-testing-ci.yml

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,17 @@ jobs:
136136
name: ui-build
137137
path: ab-testing/frontend/output/ab-tests.html
138138

139-
- name: Fetch Lambda build
139+
- name: Fetch Deploy Lambda build
140140
uses: actions/[email protected]
141141
with:
142142
name: ab-testing-lambda-build
143-
path: ab-testing/deploy-lambda/dist/lambda.zip
143+
path: ab-testing/deploy-lambda/dist/deployment-lambda.zip
144+
145+
- name: Fetch Notification Lambda build
146+
uses: actions/[email protected]
147+
with:
148+
name: ab-testing-notification-build
149+
path: ab-testing/notification-lambda/dist/notification-lambda.zip
144150

145151
- name: CDK Test
146152
run: pnpm --filter @guardian/ab-testing-cdk test
@@ -159,7 +165,9 @@ jobs:
159165
ab-testing-config-artifacts:
160166
- ab-testing/config/dist
161167
ab-testing-deployment-lambda:
162-
- ab-testing/deploy-lambda/dist/lambda.zip
168+
- ab-testing/deploy-lambda/dist/deployment-lambda.zip
169+
ab-testing-notification-lambda:
170+
- ab-testing/notification-lambda/dist/notification-lambda.zip
163171
ab-testing-ui-artifact:
164172
- ab-testing/frontend/output/ab-tests.html
165173
cdk.out:

ab-testing/cdk/lib/deploymentLambda.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export class AbTestingDeploymentLambda extends GuStack {
4141

4242
const lambda = new GuLambdaFunction(this, "AbTestingDeploymentLambda", {
4343
functionName: `${lambdaFunctionName}-${this.stage}`,
44-
fileName: "lambda.zip",
44+
fileName: "deployment-lambda.zip",
4545
handler: "index.handler",
4646
app: lambdaFunctionName,
4747
runtime: Runtime.NODEJS_22_X,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { GuScheduledLambda } from "@guardian/cdk";
2+
import {
3+
GuStack,
4+
type GuStackProps,
5+
} from "@guardian/cdk/lib/constructs/core/stack.js";
6+
import type { App } from "aws-cdk-lib";
7+
import { Schedule } from "aws-cdk-lib/aws-events";
8+
import { Runtime } from "aws-cdk-lib/aws-lambda";
9+
10+
export const lambdaFunctionName = "ab-testing-notification-lambda";
11+
12+
export class AbTestingNotificationLambda extends GuStack {
13+
constructor(scope: App, id: string, props: GuStackProps) {
14+
super(scope, id, props);
15+
16+
// const lambda =
17+
new GuScheduledLambda(this, "AbTestingNotificationLambda", {
18+
app: lambdaFunctionName,
19+
fileName: "notification-lambda.zip",
20+
handler: "index.handler",
21+
rules: [
22+
{
23+
schedule: Schedule.expression("cron(0 8 ? * MON-FRI *)"),
24+
description: "Daily expiry checks for AB testing",
25+
},
26+
],
27+
monitoringConfiguration: { noMonitoring: true },
28+
runtime: Runtime.NODEJS_22_X,
29+
});
30+
}
31+
}

ab-testing/config/abTests.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ const ABTests: ABTest[] = [
3737
description:
3838
"Tests whether we can get the users email, hash it and pass pd value to the userId array",
3939
owners: ["[email protected]"],
40-
expirationDate: `2026-01-15`,
40+
expirationDate: `2025-12-15`,
4141
type: "client",
4242
status: "OFF",
4343
audienceSize: 10 / 100,

ab-testing/config/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// this file is covered by the dotcom-rendering tsconfig.json,
22
// not the one in this directory (hence no need for .ts extension)
3-
import { activeABtests, allABTests } from "./abTests";
4-
5-
export { allABTests, activeABtests };
3+
export { activeABtests, allABTests } from "./abTests";
4+
export type { ABTest } from "./types";
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# @guardian/ab-testing-deploy-lambda
2+
3+
This directory contains the AWS Lambda function responsible for deploying AB testing configurations to Fastly.
4+
5+
The lambda is triggered during cloudformation to update the AB testing configurations stored in Fastly dictionaries. It reads the latest AB test definitions and their associated MVT IDs, then updates the Fastly service accordingly.
6+
7+
It is implemented to be invoked as a [`CustomResource`](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/template-custom-resources.html), which is a way to extend CloudFormation functionality by writing custom provisioning logic in an AWS Lambda.
8+
9+
## How it works
10+
11+
When the deployment lambda is invoked, it performs the following steps:
12+
13+
1. Reads the AB test definitions and MVT ID mappings from the artifacts located in S3.
14+
2. Connects to the Fastly API using credentials from AWS Secrets Manager.
15+
3. Updates the relevant Fastly dictionaries with the new AB test configurations.
16+
17+
## Local Development
18+
19+
Get `frontend` credentials from Janus.
20+
21+
install dependencies:
22+
23+
```bash
24+
pnpm install
25+
```
26+
27+
Call the `run.ts` script, (be sure to announce you're using the CODE stage in the semaphore chat channel):
28+
29+
```bash
30+
STAGE=CODE ARTIFACT_BUCKET_NAME=the-bucket node src/run.ts
31+
```
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"name": "@guardian/ab-testing-notification-lambda",
3+
"version": "1.0.0",
4+
"description": "Scheduled Lambda that checks expiry dates for active AB tests and notifies if close to expiry or has expired",
5+
"main": "index.ts",
6+
"type": "module",
7+
"scripts": {
8+
"build": "rollup -c rollup.config.js",
9+
"test": "node --test --experimental-test-module-mocks --test-reporter spec './**/*.test.ts'",
10+
"lint": "eslint .",
11+
"prettier:check": "prettier . --check --cache",
12+
"prettier:fix": "prettier . --write --cache"
13+
},
14+
"dependencies": {
15+
"@aws-sdk/client-ses": "3.958.0",
16+
"@guardian/ab-testing-config": "workspace:ab-testing-config"
17+
},
18+
"devDependencies": {
19+
"@guardian/eslint-config": "12.0.1",
20+
"@guardian/tsconfig": "1.0.1",
21+
"@rollup/plugin-commonjs": "29.0.0",
22+
"@rollup/plugin-json": "6.1.0",
23+
"@rollup/plugin-node-resolve": "16.0.3",
24+
"@types/aws-lambda": "8.10.158",
25+
"@types/node": "22.17.0",
26+
"aws-lambda": "1.0.7",
27+
"esbuild": "0.27.0",
28+
"eslint": "9.39.1",
29+
"prettier": "3.0.3",
30+
"rollup": "4.53.2",
31+
"rollup-plugin-esbuild": "6.2.1",
32+
"type-fest": "4.21.0",
33+
"typescript": "5.9.3"
34+
}
35+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import commonjs from "@rollup/plugin-commonjs";
2+
import json from "@rollup/plugin-json";
3+
import { nodeResolve } from "@rollup/plugin-node-resolve";
4+
import esbuild from "rollup-plugin-esbuild";
5+
6+
/** @type {import('rollup').RollupOptions} */
7+
const rollupConfig = {
8+
input: `src/index.ts`,
9+
output: {
10+
dir: "dist",
11+
format: "module",
12+
preserveModules: true,
13+
preserveModulesRoot: "src",
14+
sourcemap: true,
15+
entryFileNames: (chunkInfo) => {
16+
if (chunkInfo.name.includes("node_modules")) {
17+
// Simplify node_modules paths, if it's in node_modules without a package.json
18+
// it'll be assumed to be a commonjs module, which is incorrect and causes issues.
19+
return (
20+
chunkInfo.name.replace(
21+
/node_modules\/\.pnpm\/.*\/node_modules/,
22+
"external",
23+
) + ".js"
24+
);
25+
}
26+
27+
return "[name].js";
28+
},
29+
},
30+
external: ["@aws-sdk/*"],
31+
plugins: [
32+
commonjs(),
33+
nodeResolve({
34+
preferBuiltins: true,
35+
exportConditions: ["node"],
36+
resolveOnly: (moduleName) => !moduleName.startsWith("@aws-sdk"),
37+
}),
38+
json(),
39+
esbuild({
40+
include: /\.[jt]s?$/,
41+
}),
42+
],
43+
};
44+
45+
export default rollupConfig;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { activeABtests } from "@guardian/ab-testing-config";
2+
import { ABExpiryChecks, checkExpiry } from "./lib/checkExpiry";
3+
import { sendEmail } from "./lib/email";
4+
5+
const getMessageBody = (
6+
recipient: string,
7+
checks: Record<string, unknown[]>,
8+
) => {
9+
return `
10+
<h1>AB Tests Expiring soon</h1>
11+
${recipient}
12+
${checks}
13+
`;
14+
};
15+
16+
const arrangeByEmail = (
17+
checks: ABExpiryChecks,
18+
): Array<[string, ABExpiryChecks]> => {
19+
// TODO
20+
return [["email", checks]];
21+
};
22+
23+
export const handler = async (): Promise<void> => {
24+
const expiryChecks = checkExpiry(activeABtests);
25+
26+
const expiryChecksByEmail = arrangeByEmail(expiryChecks);
27+
28+
expiryChecksByEmail.forEach(
29+
([email, checks]: [string, Record<string, unknown[]>]) => {
30+
const message = getMessageBody(email, checks);
31+
sendEmail([email], message);
32+
},
33+
);
34+
};

0 commit comments

Comments
 (0)