-
Notifications
You must be signed in to change notification settings - Fork 6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
aid alarms-handler testability by making the main logic a more pure function #2635
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -122,10 +122,12 @@ const teamToAppMappings: Record<Team, string[]> = { | |
], | ||
}; | ||
|
||
const buildAppToTeamMappings = (): Record<string, Team[]> => { | ||
const buildAppToTeamMappings = ( | ||
theMappings: Record<Team, string[]>, | ||
): Record<string, Team[]> => { | ||
const mappings: Record<string, Team[]> = {}; | ||
|
||
for (const [team, apps] of Object.entries(teamToAppMappings)) { | ||
for (const [team, apps] of Object.entries(theMappings)) { | ||
for (const app of apps) { | ||
const teams = mappings[app] ?? []; | ||
teams.push(team as Team); | ||
|
@@ -136,19 +138,33 @@ const buildAppToTeamMappings = (): Record<string, Team[]> => { | |
return mappings; | ||
}; | ||
|
||
const appToTeamMappings: Record<string, Team[]> = buildAppToTeamMappings(); | ||
export type AlarmMappings = { | ||
getTeams: (appName?: string) => Team[]; | ||
getTeamWebhookUrl: (team: Team) => string; | ||
}; | ||
Comment on lines
+141
to
+144
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. in the previous PR I did a class as that gives you the type for free, but I wasn't sure how to do similar as function based (assuming this is even what was meant) - perhaps there's a typeof trick I should be using rather than replicating the function signatures here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think defining the type to be returned by the function here is good. Regarding "replicating the function signatures" - I think what you have here is fine. It is possible to reference the type of a field as follows (though I don't think it's necessary here): There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thanks Tom for the link and I will leave it as it is then, I agree on the referencing thing. |
||
|
||
export const getTeams = (appName?: string): Team[] => { | ||
if (appName && appToTeamMappings[appName]) { | ||
return appToTeamMappings[appName]; | ||
} | ||
export const AlarmMappings: ( | ||
johnduffell marked this conversation as resolved.
Show resolved
Hide resolved
|
||
mappings: Record<string, string[]>, | ||
) => AlarmMappings = (mappings: Record<string, string[]>) => { | ||
const appToTeamMappings: Record<string, Team[]> = | ||
buildAppToTeamMappings(mappings); | ||
|
||
return ['SRE']; | ||
}; | ||
const getTeams = (appName?: string): Team[] => { | ||
if (appName && appToTeamMappings[appName]) { | ||
return appToTeamMappings[appName]; | ||
} | ||
|
||
export const getTeamWebhookUrl = (team: Team): string => { | ||
return getIfDefined<string>( | ||
process.env[`${team}_WEBHOOK`], | ||
`${team}_WEBHOOK environment variable not set`, | ||
); | ||
return ['SRE']; | ||
}; | ||
|
||
const getTeamWebhookUrl = (team: Team): string => { | ||
return getIfDefined<string>( | ||
process.env[`${team}_WEBHOOK`], | ||
`${team}_WEBHOOK environment variable not set`, | ||
); | ||
}; | ||
|
||
return { getTeams, getTeamWebhookUrl }; | ||
}; | ||
|
||
export const ProdAlarmMappings = AlarmMappings(teamToAppMappings); |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -1,6 +1,7 @@ | ||||||
import type { SNSEventRecord, SQSEvent } from 'aws-lambda'; | ||||||
import type { SNSEventRecord, SQSEvent, SQSRecord } from 'aws-lambda'; | ||||||
import { z } from 'zod'; | ||||||
import { getTeams, getTeamWebhookUrl } from './alarmMappings'; | ||||||
import type { AlarmMappings } from './alarmMappings'; | ||||||
import { ProdAlarmMappings } from './alarmMappings'; | ||||||
import { getAppNameTag } from './cloudwatch'; | ||||||
|
||||||
const cloudWatchAlarmMessageSchema = z.object({ | ||||||
|
@@ -17,23 +18,21 @@ type CloudWatchAlarmMessage = z.infer<typeof cloudWatchAlarmMessageSchema>; | |||||
export const handler = async (event: SQSEvent): Promise<void> => { | ||||||
try { | ||||||
for (const record of event.Records) { | ||||||
console.log(record); | ||||||
|
||||||
const { Message, MessageAttributes } = JSON.parse( | ||||||
record.body, | ||||||
) as SNSEventRecord['Sns']; | ||||||
|
||||||
const parsedMessage = attemptToParseMessageString({ | ||||||
messageString: Message, | ||||||
}); | ||||||
|
||||||
if (parsedMessage) { | ||||||
await handleCloudWatchAlarmMessage({ message: parsedMessage }); | ||||||
} else { | ||||||
await handleSnsPublishMessage({ | ||||||
message: Message, | ||||||
messageAttributes: MessageAttributes, | ||||||
}); | ||||||
const maybeChatMessages = await getChatMessages( | ||||||
record, | ||||||
ProdAlarmMappings, | ||||||
); | ||||||
|
||||||
if (maybeChatMessages) { | ||||||
await Promise.all( | ||||||
maybeChatMessages.webhookUrls.map((webhookUrl) => { | ||||||
return fetch(webhookUrl, { | ||||||
method: 'POST', | ||||||
headers: { 'Content-Type': 'application/json' }, | ||||||
body: JSON.stringify({ text: maybeChatMessages.text }), | ||||||
}); | ||||||
}), | ||||||
); | ||||||
} | ||||||
} | ||||||
} catch (error) { | ||||||
|
@@ -42,6 +41,44 @@ export const handler = async (event: SQSEvent): Promise<void> => { | |||||
} | ||||||
}; | ||||||
|
||||||
export async function getChatMessages( | ||||||
record: SQSRecord, | ||||||
alarmMappings: AlarmMappings, | ||||||
) { | ||||||
console.log('sqsRecord', record); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. return type? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it's Last year someone in the team told me in typescript you are supposed to avoid specifying return types as it can break your code at run time. Someone sent me some videos about it which I watched, but I forgot the exact reasoning, and can't find them any more. Perhaps some of these? https://marcoheine.com/today-i-learned/dont-use-return-types-in-typescript There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've not heard that before... There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. great in that case I've added it in! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. aha here was the original note suggesting to avoid them which I clearly took some time to digest! #2317 (comment) |
||||||
|
||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should these There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I intentionally left them as I think this is the correct way to log things in cloudwatch in a JS lambda, if that's right? |
||||||
const { Message, MessageAttributes } = JSON.parse( | ||||||
record.body, | ||||||
) as SNSEventRecord['Sns']; | ||||||
|
||||||
console.log('snsEvent', Message, MessageAttributes); | ||||||
|
||||||
const parsedMessage = attemptToParseMessageString({ | ||||||
messageString: Message, | ||||||
}); | ||||||
|
||||||
console.log('parsedMessage', parsedMessage); | ||||||
|
||||||
let message; | ||||||
if (parsedMessage) { | ||||||
message = await handleCloudWatchAlarmMessage({ message: parsedMessage }); | ||||||
} else { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. good spot, same for |
||||||
message = handleSnsPublishMessage({ | ||||||
message: Message, | ||||||
messageAttributes: MessageAttributes, | ||||||
}); | ||||||
} | ||||||
|
||||||
if (message) { | ||||||
const teams = alarmMappings.getTeams(message.app); | ||||||
console.log('sending message to teams', teams); | ||||||
const webhookUrls = teams.map(alarmMappings.getTeamWebhookUrl); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we keep this log line, what about
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. thanks, went for |
||||||
return { webhookUrls, text: message.text }; | ||||||
} else { | ||||||
return undefined; | ||||||
} | ||||||
} | ||||||
Comment on lines
+77
to
+79
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Totally subjective, but we don't need this else block I don't think. With no explicit return There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. got it, so it's like of like mapping an Option in scala There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh weird sorry about that. Maybe it's the fact that the return value is wrapped in a promise? Best to leave it as is (which I think you've done!) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
|
||||||
const attemptToParseMessageString = ({ | ||||||
messageString, | ||||||
}: { | ||||||
|
@@ -70,32 +107,21 @@ const handleCloudWatchAlarmMessage = async ({ | |||||
} = message; | ||||||
|
||||||
const app = await getAppNameTag(AlarmArn, AWSAccountId); | ||||||
const teams = getTeams(app); | ||||||
|
||||||
await Promise.all( | ||||||
teams.map((team) => { | ||||||
const webhookUrl = getTeamWebhookUrl(team); | ||||||
|
||||||
const title = | ||||||
NewStateValue === 'OK' | ||||||
? `✅ *ALARM OK:* ${AlarmName} has recovered!` | ||||||
: `🚨 *ALARM:* ${AlarmName} has triggered!`; | ||||||
const text = `${title}\n\n*Description:* ${ | ||||||
AlarmDescription ?? '' | ||||||
}\n\n*Reason:* ${NewStateReason}`; | ||||||
|
||||||
console.log(`CloudWatch alarm from ${app} owned by ${team}`); | ||||||
|
||||||
return fetch(webhookUrl, { | ||||||
method: 'POST', | ||||||
headers: { 'Content-Type': 'application/json' }, | ||||||
body: JSON.stringify({ text }), | ||||||
}); | ||||||
}), | ||||||
); | ||||||
|
||||||
const title = | ||||||
NewStateValue === 'OK' | ||||||
? `✅ *ALARM OK:* ${AlarmName} has recovered!` | ||||||
: `🚨 *ALARM:* ${AlarmName} has triggered!`; | ||||||
const text = `${title}\n\n*Description:* ${ | ||||||
AlarmDescription ?? '' | ||||||
}\n\n*Reason:* ${NewStateReason}`; | ||||||
|
||||||
console.log(`CloudWatch alarm from ${app}`); | ||||||
|
||||||
return { app, text }; | ||||||
}; | ||||||
|
||||||
const handleSnsPublishMessage = async ({ | ||||||
const handleSnsPublishMessage = ({ | ||||||
message, | ||||||
messageAttributes, | ||||||
}: { | ||||||
|
@@ -109,21 +135,10 @@ const handleSnsPublishMessage = async ({ | |||||
} | ||||||
|
||||||
const app = messageAttributes.app?.Value; | ||||||
const teams = getTeams(app); | ||||||
|
||||||
await Promise.all( | ||||||
teams.map((team) => { | ||||||
const webhookUrl = getTeamWebhookUrl(team); | ||||||
|
||||||
const text = message; | ||||||
const text = message; | ||||||
|
||||||
console.log(`SNS publish message from ${app} owned by ${team}`); | ||||||
console.log(`SNS publish message from ${app}`); | ||||||
|
||||||
return fetch(webhookUrl, { | ||||||
method: 'POST', | ||||||
headers: { 'Content-Type': 'application/json' }, | ||||||
body: JSON.stringify({ text }), | ||||||
}); | ||||||
}), | ||||||
); | ||||||
return { app, text }; | ||||||
}; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Totally subjective, and very minor, but rather than assigning to a new const I'd probably just map from one name to the other in the return:
Suggested change
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
import { type SQSEvent } from 'aws-lambda'; | ||
import { handler } from '../src'; | ||
import type { SQSEvent, SQSRecord } from 'aws-lambda'; | ||
import { getChatMessages, handler } from '../src'; | ||
import { AlarmMappings } from '../src/alarmMappings'; | ||
import { getAppNameTag } from '../src/cloudwatch'; | ||
|
||
jest.mock('../src/cloudwatch'); | ||
|
@@ -62,6 +63,25 @@ describe('Handler', () => { | |
expect(getAppNameTag).toHaveBeenCalledWith('mock-arn', '111111'); | ||
expect(fetch).toHaveBeenCalledWith(mockEnv.SRE_WEBHOOK, expect.any(Object)); | ||
}); | ||
it('should handle captured CloudWatch alarm message', async () => { | ||
(getAppNameTag as jest.Mock).mockResolvedValueOnce('mock-app'); | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. we still have one "mocked" function but it's more minor functionality and doesn't lend itself to parameterising as easily as the alarm mappings. |
||
const result = await getChatMessages( | ||
fullCloudWatchAlarmEvent, | ||
AlarmMappings({ SRE: ['mock-app'] }), | ||
); | ||
|
||
expect(getAppNameTag).toHaveBeenCalledWith( | ||
'arn:aws:cloudwatch:eu-west-1:1234:alarm:DISCOUNT-API-CODE Discount-api 5XX response', | ||
'1234', | ||
); | ||
const expectedText = | ||
'🚨 *ALARM:* DISCOUNT-API-CODE Discount-api 5XX response has triggered!\n\n' + | ||
'*Description:* Impact - Discount api returned a 5XX response check the logs for more information: https://eu-west-1.console.aws.amazon.com/cloudwatch/home?region=eu-west-1#logsV2:log-groups/log-group/$252Faws$252Flambda$252Fdiscount-api-CODE. Follow the process in https://docs.google.com/document/d/sdkjfhskjdfhksjdhf/edit\n\n' + | ||
'*Reason:* Threshold Crossed: 1 datapoint [2.0 (09/10/24 07:18:00)] was greater than or equal to the threshold (1.0).'; | ||
expect(result?.webhookUrls).toEqual([mockEnv.SRE_WEBHOOK]); | ||
expect(result?.text).toEqual(expectedText); | ||
}); | ||
|
||
it('should handle SNS publish message', async () => { | ||
jest | ||
|
@@ -116,3 +136,64 @@ describe('Handler', () => { | |
); | ||
}); | ||
}); | ||
|
||
const fullCloudWatchAlarmEvent = { | ||
messageId: 'askjdhaskjhdjkashdakjsdjkashd', | ||
receiptHandle: 'skdfhksjdfhksjdhfkjsdhfjkhsdfksd==', | ||
body: JSON.stringify({ | ||
Type: 'Notification', | ||
MessageId: 'sdkfjhslkdfhjksjdhfkjsdhf', | ||
TopicArn: 'arn:aws:sns:eu-west-1:123456:alarms-handler-topic-CODE', | ||
Subject: | ||
'ALARM: "DISCOUNT-API-CODE Discount-api 5XX response" in EU (Ireland)', | ||
Message: JSON.stringify({ | ||
AlarmName: 'DISCOUNT-API-CODE Discount-api 5XX response', | ||
AlarmDescription: | ||
'Impact - Discount api returned a 5XX response check the logs for more information: https://eu-west-1.console.aws.amazon.com/cloudwatch/home?region=eu-west-1#logsV2:log-groups/log-group/$252Faws$252Flambda$252Fdiscount-api-CODE. Follow the process in https://docs.google.com/document/d/sdkjfhskjdfhksjdhf/edit', | ||
AWSAccountId: '1234', | ||
AlarmConfigurationUpdatedTimestamp: '2024-09-23T09:21:15.363+0000', | ||
NewStateValue: 'ALARM', | ||
NewStateReason: | ||
'Threshold Crossed: 1 datapoint [2.0 (09/10/24 07:18:00)] was greater than or equal to the threshold (1.0).', | ||
StateChangeTime: '2024-10-09T07:23:16.236+0000', | ||
Region: 'EU (Ireland)', | ||
AlarmArn: | ||
'arn:aws:cloudwatch:eu-west-1:1234:alarm:DISCOUNT-API-CODE Discount-api 5XX response', | ||
OldStateValue: 'OK', | ||
OKActions: [], | ||
AlarmActions: ['arn:aws:sns:eu-west-1:1234:alarms-handler-topic-CODE'], | ||
InsufficientDataActions: [], | ||
Trigger: { | ||
MetricName: '5XXError', | ||
Namespace: 'AWS/ApiGateway', | ||
StatisticType: 'Statistic', | ||
Statistic: 'SUM', | ||
Unit: null, | ||
Dimensions: [[Object]], | ||
Period: 300, | ||
EvaluationPeriods: 1, | ||
ComparisonOperator: 'GreaterThanOrEqualToThreshold', | ||
Threshold: 1, | ||
TreatMissingData: '', | ||
EvaluateLowSampleCountPercentile: '', | ||
}, | ||
}), | ||
Timestamp: '2024-10-09T07:23:16.318Z', | ||
SignatureVersion: '1', | ||
Signature: 'skjefhksjdhfkjsdhfkjsdhfkjsdf==', | ||
SigningCertURL: 'https://sns.eu-west-1.amazonaws.com/smhdfsmdfhgsdjf.pem', | ||
UnsubscribeURL: | ||
'https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:1234:alarms-handler-topic-CODE:sdkjfhsdkjfhskdjf', | ||
}), | ||
attributes: { | ||
ApproximateReceiveCount: '1', | ||
SentTimestamp: '1728458596353', | ||
SenderId: 'askjdhaskjdhaksdj', | ||
ApproximateFirstReceiveTimestamp: '1728458596364', | ||
}, | ||
messageAttributes: {}, | ||
md5OfBody: 'askdjalksdjlasdjlaksjd', | ||
eventSource: 'aws:sqs', | ||
eventSourceARN: 'arn:aws:sqs:eu-west-1:1234:alarms-handler-queue-CODE', | ||
awsRegion: 'eu-west-1', | ||
} as SQSRecord; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the changes in this file are simply to make the mappings parameterised rather than being a singleton.