148 lines
9.1 KiB
TypeScript
148 lines
9.1 KiB
TypeScript
import {describe, expect, test} from 'bun:test'
|
||
import {
|
||
DeleteAlarmsCommand,
|
||
DescribeAlarmHistoryCommand,
|
||
DescribeAlarmsCommand,
|
||
DisableAlarmActionsCommand,
|
||
EnableAlarmActionsCommand,
|
||
GetMetricDataCommand,
|
||
ListMetricsCommand,
|
||
PutCompositeAlarmCommand,
|
||
PutMetricAlarmCommand,
|
||
SetAlarmStateCommand,
|
||
type CloudWatchClient,
|
||
} from '@aws-sdk/client-cloudwatch'
|
||
import {AwsMonitoringAdapter} from './AwsMonitoringAdapter'
|
||
import {ValidationError} from '../cloud-spi/errors'
|
||
|
||
type SendResult = Record<string, unknown>
|
||
|
||
function stubCloudWatch(handler: (command: object) => SendResult | Promise<SendResult>) {
|
||
const sent: object[] = []
|
||
const client = {
|
||
async send(command: object) {
|
||
sent.push(command)
|
||
return handler(command)
|
||
},
|
||
} as unknown as CloudWatchClient
|
||
return {adapter: new AwsMonitoringAdapter(client), sent}
|
||
}
|
||
|
||
const UPDATED = new Date('2026-10-25T10:00:00Z')
|
||
const metricAlarm = {
|
||
AlarmName: 'high-errors', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:high-errors',
|
||
StateValue: 'ALARM', StateReason: 'Threshold crossed', StateUpdatedTimestamp: UPDATED, AlarmConfigurationUpdatedTimestamp: UPDATED,
|
||
ActionsEnabled: true, AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: [],
|
||
Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
|
||
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
|
||
}
|
||
const compositeAlarm = {
|
||
AlarmName: 'app-unhealthy', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:app-unhealthy',
|
||
StateValue: 'OK', AlarmRule: 'ALARM("high-errors")', ActionsEnabled: false, AlarmConfigurationUpdatedTimestamp: UPDATED,
|
||
}
|
||
|
||
describe('AwsMonitoringAdapter alarms', () => {
|
||
test('lists metric and composite alarms with a readable condition', async () => {
|
||
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand
|
||
? {MetricAlarms: [metricAlarm], CompositeAlarms: [compositeAlarm]}
|
||
: {})
|
||
|
||
const alarms = await adapter.list()
|
||
expect((sent[0] as DescribeAlarmsCommand).input.AlarmTypes).toEqual(['MetricAlarm', 'CompositeAlarm'])
|
||
expect(alarms.map((alarm) => [alarm.name, alarm.status, alarm.metadata.alarmType])).toEqual([['app-unhealthy', 'OK', 'composite'], ['high-errors', 'ALARM', 'metric']])
|
||
expect(alarms[1].metadata).toMatchObject({condition: 'Errors Sum > 5 for 2 × 5 min', dimensions: [{name: 'FunctionName', value: 'checkout'}], threshold: 5, periodSeconds: 300})
|
||
expect(alarms[0].metadata).toMatchObject({condition: 'ALARM("high-errors")', actionsEnabled: false})
|
||
expect(await adapter.list({search: 'errors'})).toHaveLength(1)
|
||
})
|
||
|
||
test('creates a metric alarm that notifies an SNS topic both ways', async () => {
|
||
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {MetricAlarms: [metricAlarm]} : {})
|
||
|
||
await adapter.create({values: {
|
||
alarmType: 'metric', name: 'high-errors', namespace: 'App', metricName: 'Errors', dimensions: 'FunctionName=checkout\n',
|
||
statistic: 'Sum', period: '300', comparison: 'GreaterThanThreshold', threshold: '5', evaluationPeriods: '2',
|
||
topicArn: 'arn:aws:sns:us-east-1:000000000000:alerts',
|
||
}})
|
||
|
||
expect((sent[0] as PutMetricAlarmCommand).input).toMatchObject({
|
||
AlarmName: 'high-errors', Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
|
||
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
|
||
AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: ['arn:aws:sns:us-east-1:000000000000:alerts'],
|
||
})
|
||
})
|
||
|
||
test('creates a composite alarm from its rule', async () => {
|
||
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {CompositeAlarms: [compositeAlarm]} : {})
|
||
|
||
const created = await adapter.create({values: {alarmType: 'composite', name: 'app-unhealthy', alarmRule: 'ALARM("high-errors")'}})
|
||
expect((sent[0] as PutCompositeAlarmCommand).input).toMatchObject({AlarmName: 'app-unhealthy', AlarmRule: 'ALARM("high-errors")', AlarmActions: []})
|
||
expect(created.metadata.alarmType).toBe('composite')
|
||
})
|
||
|
||
test('checks the alarm before creating it', async () => {
|
||
const {adapter, sent} = stubCloudWatch(() => ({}))
|
||
const valid = {alarmType: 'metric', name: 'a', namespace: 'App', metricName: 'Errors', threshold: '5'}
|
||
|
||
await expect(adapter.create({values: {...valid, threshold: 'lots'}})).rejects.toThrow(/threshold/)
|
||
await expect(adapter.create({values: {...valid, metricName: ''}})).rejects.toThrow(/metricName/)
|
||
await expect(adapter.create({values: {...valid, dimensions: 'FunctionName'}})).rejects.toThrow(/Name=Value/)
|
||
await expect(adapter.create({values: {...valid, topicArn: 'my-topic'}})).rejects.toThrow(/SNS topic ARN/)
|
||
await expect(adapter.create({values: {...valid, period: '7'}})).rejects.toThrow(ValidationError)
|
||
await expect(adapter.create({values: {alarmType: 'composite', name: 'b', alarmRule: ' '}})).rejects.toThrow(/rule/)
|
||
expect(sent.some((command) => command instanceof PutMetricAlarmCommand || command instanceof PutCompositeAlarmCommand)).toBe(false)
|
||
})
|
||
|
||
test('deletes, sets the state and toggles the actions of an alarm', async () => {
|
||
const {adapter, sent} = stubCloudWatch(() => ({}))
|
||
|
||
await adapter.delete('high-errors')
|
||
await adapter.setAlarmState('high-errors', 'ALARM', '')
|
||
await adapter.setAlarmActionsEnabled('high-errors', false)
|
||
await adapter.setAlarmActionsEnabled('high-errors', true)
|
||
|
||
expect((sent[0] as DeleteAlarmsCommand).input).toEqual({AlarmNames: ['high-errors']})
|
||
expect((sent[1] as SetAlarmStateCommand).input).toMatchObject({AlarmName: 'high-errors', StateValue: 'ALARM'})
|
||
expect((sent[1] as SetAlarmStateCommand).input.StateReason).toContain('TheCloud')
|
||
expect(sent[2]).toBeInstanceOf(DisableAlarmActionsCommand)
|
||
expect(sent[3]).toBeInstanceOf(EnableAlarmActionsCommand)
|
||
await expect(adapter.setAlarmState('high-errors', 'BROKEN' as never, '')).rejects.toThrow(/state/)
|
||
})
|
||
|
||
test('reads the alarm history newest first', async () => {
|
||
const {adapter, sent} = stubCloudWatch(() => ({AlarmHistoryItems: [{Timestamp: UPDATED, HistoryItemType: 'StateUpdate', HistorySummary: 'Alarm updated from OK to ALARM'}]}))
|
||
|
||
expect(await adapter.describeAlarmHistory('high-errors')).toEqual([{timestamp: '2026-10-25T10:00:00.000Z', type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
|
||
expect((sent[0] as DescribeAlarmHistoryCommand).input).toMatchObject({AlarmName: 'high-errors', ScanBy: 'TimestampDescending'})
|
||
})
|
||
})
|
||
|
||
describe('AwsMonitoringAdapter metrics', () => {
|
||
test('lists metrics sorted by namespace and filters them', async () => {
|
||
const {adapter} = stubCloudWatch((command) => command instanceof ListMetricsCommand
|
||
? {Metrics: [
|
||
{Namespace: 'AWS/SQS', MetricName: 'NumberOfMessagesSent', Dimensions: [{Name: 'QueueName', Value: 'orders'}]},
|
||
{Namespace: 'App', MetricName: 'Errors', Dimensions: []},
|
||
]}
|
||
: {})
|
||
|
||
expect((await adapter.listMetrics()).map((metric) => `${metric.namespace}/${metric.metricName}`)).toEqual(['App/Errors', 'AWS/SQS/NumberOfMessagesSent'])
|
||
expect(await adapter.listMetrics({search: 'orders'})).toHaveLength(1)
|
||
})
|
||
|
||
test('charts a metric with a period that suits the range', async () => {
|
||
const {adapter, sent} = stubCloudWatch((command) => command instanceof GetMetricDataCommand
|
||
? {MetricDataResults: [{Id: 'm1', Label: 'Errors', Timestamps: [new Date('2026-10-25T10:01:00Z'), new Date('2026-10-25T10:00:00Z')], Values: [7, 3]}]}
|
||
: {})
|
||
|
||
const series = await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 60, dimensions: [{name: 'FunctionName', value: 'checkout'}]})
|
||
expect(series).toEqual({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00.000Z', value: 3}, {timestamp: '2026-10-25T10:01:00.000Z', value: 7}]})
|
||
const query = (sent[0] as GetMetricDataCommand).input.MetricDataQueries![0]
|
||
expect(query.MetricStat).toEqual({Metric: {Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}]}, Period: 60, Stat: 'Sum'})
|
||
|
||
await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 24 * 60})
|
||
expect((sent[1] as GetMetricDataCommand).input.MetricDataQueries![0].MetricStat!.Period).toBe(300)
|
||
await expect(adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 1})).rejects.toThrow(/rangeMinutes/)
|
||
await expect(adapter.getMetricSeries({namespace: '', metricName: 'Errors'})).rejects.toThrow(/namespace/)
|
||
})
|
||
})
|