Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/aws

src/metrics.ts

157 lines · 4.2 KB · TypeScript
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