import { awsSignedFetch } from './client.ts'
import {
boundedPageSize,
inputRecord,
optionalString,
requiredString,
type AwsAuthOptions,
} from './validation.ts'
function formBody(fields: Record<string, string | undefined>): string {
const params = new URLSearchParams()
for (const [key, value] of Object.entries(fields)) {
if (value === undefined || value === '') continue
params.set(key, value)
}
return params.toString()
}
/**
* List CloudWatch metrics. Namespaces and metric names only.
*
* @example
* import { listMetrics } from 'kody:@kody/aws/metrics'
* const { items } = await listMetrics({ namespace: 'AWS/Lambda' })
*/
export async function listMetrics(
params: AwsAuthOptions & {
namespace?: string
metricName?: string
pageSize?: number
} = {},
) {
const input = inputRecord(params)
const result = await awsSignedFetch({
...params,
service: 'monitoring',
method: 'POST',
path: '/',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
body: formBody({
Action: 'ListMetrics',
Version: '2010-08-01',
Namespace: optionalString(input, 'namespace'),
MetricName: optionalString(input, 'metricName'),
}),
})
const names = Array.from(
result.text.matchAll(/<MetricName>([\s\S]*?)<\/MetricName>/g),
).map((match) => match[1]?.trim() ?? '')
const namespaces = Array.from(
result.text.matchAll(/<Namespace>([\s\S]*?)<\/Namespace>/g),
).map((match) => match[1]?.trim() ?? '')
const items = names.map((metricName, index) => ({
metricName,
namespace: namespaces[index] ?? null,
}))
return {
ok: true,
region: result.region,
items: items.slice(0, boundedPageSize(input.pageSize, 50, 200)),
}
}
/**
* Read one CloudWatch statistic for a metric. Pass namespace, metric name,
* and a statistic — no baked-in account resources.
*
* @example
* import { getMetricStatistics } from 'kody:@kody/aws/metrics'
* const stats = await getMetricStatistics({
* namespace: 'AWS/S3',
* metricName: 'NumberOfObjects',
* })
*/
export async function getMetricStatistics(
params: AwsAuthOptions & {
namespace: string
metricName: string
statistic?: string
periodSeconds?: number
hours?: number
},
) {
const input = inputRecord(params)
const namespace = requiredString(input, 'namespace')
const metricName = requiredString(input, 'metricName')
const statistic = optionalString(input, 'statistic') ?? 'Average'
const period = Number(input.periodSeconds ?? 300)
const hours = Number(input.hours ?? 1)
if (!Number.isInteger(period) || period < 60) {
throw new Error('periodSeconds must be an integer of at least 60.')
}
if (!Number.isFinite(hours) || hours <= 0 || hours > 24) {
throw new Error('hours must be between 0 exclusive and 24 inclusive.')
}
const end = new Date()
const start = new Date(end.getTime() - hours * 60 * 60 * 1000)
const result = await awsSignedFetch({
...params,
service: 'monitoring',
method: 'POST',
path: '/',
headers: {
'content-type': 'application/x-www-form-urlencoded',
},
body: formBody({
Action: 'GetMetricStatistics',
Version: '2010-08-01',
Namespace: namespace,
MetricName: metricName,
StartTime: start.toISOString(),
EndTime: end.toISOString(),
Period: String(period),
'Statistics.member.1': statistic,
}),
})
const datapoints = Array.from(
result.text.matchAll(/<member>([\s\S]*?)<\/member>/g),
)
.map((match) => match[1] ?? '')
.filter((block) => block.includes('<Timestamp>'))
.map((block) => ({
timestamp: block.match(/<Timestamp>([\s\S]*?)<\/Timestamp>/)?.[1] ?? null,
average: numberTag(block, 'Average'),
sum: numberTag(block, 'Sum'),
maximum: numberTag(block, 'Maximum'),
minimum: numberTag(block, 'Minimum'),
sampleCount: numberTag(block, 'SampleCount'),
unit: block.match(/<Unit>([\s\S]*?)<\/Unit>/)?.[1] ?? null,
}))
return {
ok: true,
region: result.region,
namespace,
metricName,
statistic,
label:
result.text.match(/<Label>([\s\S]*?)<\/Label>/)?.[1] ?? metricName,
datapoints,
}
}
function numberTag(xml: string, tag: string): number | null {
const match = xml.match(new RegExp('<' + tag + '>([\\s\\S]*?)</' + tag + '>'))
if (!match?.[1]) return null
const value = Number(match[1])
return Number.isFinite(value) ? value : null
}
export default listMetrics