Skip to content

Commit d290e06

Browse files
TheodoreSpeaksTheodore Li
andauthored
feat(block): Add cloudwatch block (#3911)
* feat(block): add cloudwatch integration * Fix bun lock * Add logger, use execution timeout * Switch metric dimensions to map style input * Fix attribute names for dimension map * Fix import styling --------- Co-authored-by: Theodore Li <theo@sim.ai>
1 parent ace8779 commit d290e06

File tree

26 files changed

+2250
-0
lines changed

26 files changed

+2250
-0
lines changed
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
type AlarmType,
3+
CloudWatchClient,
4+
DescribeAlarmsCommand,
5+
type StateValue,
6+
} from '@aws-sdk/client-cloudwatch'
7+
import { createLogger } from '@sim/logger'
8+
import { type NextRequest, NextResponse } from 'next/server'
9+
import { z } from 'zod'
10+
import { checkInternalAuth } from '@/lib/auth/hybrid'
11+
12+
const logger = createLogger('CloudWatchDescribeAlarms')
13+
14+
const DescribeAlarmsSchema = z.object({
15+
region: z.string().min(1, 'AWS region is required'),
16+
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
17+
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
18+
alarmNamePrefix: z.string().optional(),
19+
stateValue: z.preprocess(
20+
(v) => (v === '' ? undefined : v),
21+
z.enum(['OK', 'ALARM', 'INSUFFICIENT_DATA']).optional()
22+
),
23+
alarmType: z.preprocess(
24+
(v) => (v === '' ? undefined : v),
25+
z.enum(['MetricAlarm', 'CompositeAlarm']).optional()
26+
),
27+
limit: z.preprocess(
28+
(v) => (v === '' || v === undefined || v === null ? undefined : v),
29+
z.number({ coerce: true }).int().positive().optional()
30+
),
31+
})
32+
33+
export async function POST(request: NextRequest) {
34+
try {
35+
const auth = await checkInternalAuth(request)
36+
if (!auth.success || !auth.userId) {
37+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
38+
}
39+
40+
const body = await request.json()
41+
const validatedData = DescribeAlarmsSchema.parse(body)
42+
43+
const client = new CloudWatchClient({
44+
region: validatedData.region,
45+
credentials: {
46+
accessKeyId: validatedData.accessKeyId,
47+
secretAccessKey: validatedData.secretAccessKey,
48+
},
49+
})
50+
51+
const command = new DescribeAlarmsCommand({
52+
...(validatedData.alarmNamePrefix && { AlarmNamePrefix: validatedData.alarmNamePrefix }),
53+
...(validatedData.stateValue && { StateValue: validatedData.stateValue as StateValue }),
54+
...(validatedData.alarmType && { AlarmTypes: [validatedData.alarmType as AlarmType] }),
55+
...(validatedData.limit !== undefined && { MaxRecords: validatedData.limit }),
56+
})
57+
58+
const response = await client.send(command)
59+
60+
const metricAlarms = (response.MetricAlarms ?? []).map((a) => ({
61+
alarmName: a.AlarmName ?? '',
62+
alarmArn: a.AlarmArn ?? '',
63+
stateValue: a.StateValue ?? 'UNKNOWN',
64+
stateReason: a.StateReason ?? '',
65+
metricName: a.MetricName,
66+
namespace: a.Namespace,
67+
comparisonOperator: a.ComparisonOperator,
68+
threshold: a.Threshold,
69+
evaluationPeriods: a.EvaluationPeriods,
70+
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
71+
}))
72+
73+
const compositeAlarms = (response.CompositeAlarms ?? []).map((a) => ({
74+
alarmName: a.AlarmName ?? '',
75+
alarmArn: a.AlarmArn ?? '',
76+
stateValue: a.StateValue ?? 'UNKNOWN',
77+
stateReason: a.StateReason ?? '',
78+
metricName: undefined,
79+
namespace: undefined,
80+
comparisonOperator: undefined,
81+
threshold: undefined,
82+
evaluationPeriods: undefined,
83+
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
84+
}))
85+
86+
return NextResponse.json({
87+
success: true,
88+
output: { alarms: [...metricAlarms, ...compositeAlarms] },
89+
})
90+
} catch (error) {
91+
const errorMessage =
92+
error instanceof Error ? error.message : 'Failed to describe CloudWatch alarms'
93+
logger.error('DescribeAlarms failed', { error: errorMessage })
94+
return NextResponse.json({ error: errorMessage }, { status: 500 })
95+
}
96+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs'
2+
import { createLogger } from '@sim/logger'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { z } from 'zod'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils'
7+
8+
const logger = createLogger('CloudWatchDescribeLogGroups')
9+
10+
const DescribeLogGroupsSchema = z.object({
11+
region: z.string().min(1, 'AWS region is required'),
12+
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
13+
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
14+
prefix: z.string().optional(),
15+
limit: z.preprocess(
16+
(v) => (v === '' || v === undefined || v === null ? undefined : v),
17+
z.number({ coerce: true }).int().positive().optional()
18+
),
19+
})
20+
21+
export async function POST(request: NextRequest) {
22+
try {
23+
const auth = await checkSessionOrInternalAuth(request)
24+
if (!auth.success || !auth.userId) {
25+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
26+
}
27+
28+
const body = await request.json()
29+
const validatedData = DescribeLogGroupsSchema.parse(body)
30+
31+
const client = createCloudWatchLogsClient({
32+
region: validatedData.region,
33+
accessKeyId: validatedData.accessKeyId,
34+
secretAccessKey: validatedData.secretAccessKey,
35+
})
36+
37+
const command = new DescribeLogGroupsCommand({
38+
...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }),
39+
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
40+
})
41+
42+
const response = await client.send(command)
43+
44+
const logGroups = (response.logGroups ?? []).map((lg) => ({
45+
logGroupName: lg.logGroupName ?? '',
46+
arn: lg.arn ?? '',
47+
storedBytes: lg.storedBytes ?? 0,
48+
retentionInDays: lg.retentionInDays,
49+
creationTime: lg.creationTime,
50+
}))
51+
52+
return NextResponse.json({
53+
success: true,
54+
output: { logGroups },
55+
})
56+
} catch (error) {
57+
const errorMessage =
58+
error instanceof Error ? error.message : 'Failed to describe CloudWatch log groups'
59+
logger.error('DescribeLogGroups failed', { error: errorMessage })
60+
return NextResponse.json({ error: errorMessage }, { status: 500 })
61+
}
62+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { z } from 'zod'
4+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
5+
6+
const logger = createLogger('CloudWatchDescribeLogStreams')
7+
8+
import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils'
9+
10+
const DescribeLogStreamsSchema = z.object({
11+
region: z.string().min(1, 'AWS region is required'),
12+
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
13+
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
14+
logGroupName: z.string().min(1, 'Log group name is required'),
15+
prefix: z.string().optional(),
16+
limit: z.preprocess(
17+
(v) => (v === '' || v === undefined || v === null ? undefined : v),
18+
z.number({ coerce: true }).int().positive().optional()
19+
),
20+
})
21+
22+
export async function POST(request: NextRequest) {
23+
try {
24+
const auth = await checkSessionOrInternalAuth(request)
25+
if (!auth.success || !auth.userId) {
26+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
27+
}
28+
29+
const body = await request.json()
30+
const validatedData = DescribeLogStreamsSchema.parse(body)
31+
32+
const client = createCloudWatchLogsClient({
33+
region: validatedData.region,
34+
accessKeyId: validatedData.accessKeyId,
35+
secretAccessKey: validatedData.secretAccessKey,
36+
})
37+
38+
const result = await describeLogStreams(client, validatedData.logGroupName, {
39+
prefix: validatedData.prefix,
40+
limit: validatedData.limit,
41+
})
42+
43+
return NextResponse.json({
44+
success: true,
45+
output: { logStreams: result.logStreams },
46+
})
47+
} catch (error) {
48+
const errorMessage =
49+
error instanceof Error ? error.message : 'Failed to describe CloudWatch log streams'
50+
logger.error('DescribeLogStreams failed', { error: errorMessage })
51+
return NextResponse.json({ error: errorMessage }, { status: 500 })
52+
}
53+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { z } from 'zod'
4+
import { checkInternalAuth } from '@/lib/auth/hybrid'
5+
6+
const logger = createLogger('CloudWatchGetLogEvents')
7+
8+
import { createCloudWatchLogsClient, getLogEvents } from '@/app/api/tools/cloudwatch/utils'
9+
10+
const GetLogEventsSchema = z.object({
11+
region: z.string().min(1, 'AWS region is required'),
12+
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
13+
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
14+
logGroupName: z.string().min(1, 'Log group name is required'),
15+
logStreamName: z.string().min(1, 'Log stream name is required'),
16+
startTime: z.number({ coerce: true }).int().optional(),
17+
endTime: z.number({ coerce: true }).int().optional(),
18+
limit: z.preprocess(
19+
(v) => (v === '' || v === undefined || v === null ? undefined : v),
20+
z.number({ coerce: true }).int().positive().optional()
21+
),
22+
})
23+
24+
export async function POST(request: NextRequest) {
25+
try {
26+
const auth = await checkInternalAuth(request)
27+
if (!auth.success || !auth.userId) {
28+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
29+
}
30+
31+
const body = await request.json()
32+
const validatedData = GetLogEventsSchema.parse(body)
33+
34+
const client = createCloudWatchLogsClient({
35+
region: validatedData.region,
36+
accessKeyId: validatedData.accessKeyId,
37+
secretAccessKey: validatedData.secretAccessKey,
38+
})
39+
40+
const result = await getLogEvents(
41+
client,
42+
validatedData.logGroupName,
43+
validatedData.logStreamName,
44+
{
45+
startTime: validatedData.startTime,
46+
endTime: validatedData.endTime,
47+
limit: validatedData.limit,
48+
}
49+
)
50+
51+
return NextResponse.json({
52+
success: true,
53+
output: { events: result.events },
54+
})
55+
} catch (error) {
56+
const errorMessage =
57+
error instanceof Error ? error.message : 'Failed to get CloudWatch log events'
58+
logger.error('GetLogEvents failed', { error: errorMessage })
59+
return NextResponse.json({ error: errorMessage }, { status: 500 })
60+
}
61+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cloudwatch'
2+
import { createLogger } from '@sim/logger'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { z } from 'zod'
5+
import { checkInternalAuth } from '@/lib/auth/hybrid'
6+
7+
const logger = createLogger('CloudWatchGetMetricStatistics')
8+
9+
const GetMetricStatisticsSchema = z.object({
10+
region: z.string().min(1, 'AWS region is required'),
11+
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
12+
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
13+
namespace: z.string().min(1, 'Namespace is required'),
14+
metricName: z.string().min(1, 'Metric name is required'),
15+
startTime: z.number({ coerce: true }).int(),
16+
endTime: z.number({ coerce: true }).int(),
17+
period: z.number({ coerce: true }).int().min(1),
18+
statistics: z.array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])).min(1),
19+
dimensions: z.string().optional(),
20+
})
21+
22+
export async function POST(request: NextRequest) {
23+
try {
24+
const auth = await checkInternalAuth(request)
25+
if (!auth.success || !auth.userId) {
26+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
27+
}
28+
29+
const body = await request.json()
30+
const validatedData = GetMetricStatisticsSchema.parse(body)
31+
32+
const client = new CloudWatchClient({
33+
region: validatedData.region,
34+
credentials: {
35+
accessKeyId: validatedData.accessKeyId,
36+
secretAccessKey: validatedData.secretAccessKey,
37+
},
38+
})
39+
40+
let parsedDimensions: { Name: string; Value: string }[] | undefined
41+
if (validatedData.dimensions) {
42+
try {
43+
const dims = JSON.parse(validatedData.dimensions)
44+
if (Array.isArray(dims)) {
45+
parsedDimensions = dims.map((d: Record<string, string>) => ({
46+
Name: d.name,
47+
Value: d.value,
48+
}))
49+
} else if (typeof dims === 'object') {
50+
parsedDimensions = Object.entries(dims).map(([name, value]) => ({
51+
Name: name,
52+
Value: String(value),
53+
}))
54+
}
55+
} catch {
56+
throw new Error('Invalid dimensions JSON')
57+
}
58+
}
59+
60+
const command = new GetMetricStatisticsCommand({
61+
Namespace: validatedData.namespace,
62+
MetricName: validatedData.metricName,
63+
StartTime: new Date(validatedData.startTime * 1000),
64+
EndTime: new Date(validatedData.endTime * 1000),
65+
Period: validatedData.period,
66+
Statistics: validatedData.statistics,
67+
...(parsedDimensions && { Dimensions: parsedDimensions }),
68+
})
69+
70+
const response = await client.send(command)
71+
72+
const datapoints = (response.Datapoints ?? [])
73+
.sort((a, b) => (a.Timestamp?.getTime() ?? 0) - (b.Timestamp?.getTime() ?? 0))
74+
.map((dp) => ({
75+
timestamp: dp.Timestamp ? Math.floor(dp.Timestamp.getTime() / 1000) : 0,
76+
average: dp.Average,
77+
sum: dp.Sum,
78+
minimum: dp.Minimum,
79+
maximum: dp.Maximum,
80+
sampleCount: dp.SampleCount,
81+
unit: dp.Unit,
82+
}))
83+
84+
return NextResponse.json({
85+
success: true,
86+
output: {
87+
label: response.Label ?? validatedData.metricName,
88+
datapoints,
89+
},
90+
})
91+
} catch (error) {
92+
const errorMessage =
93+
error instanceof Error ? error.message : 'Failed to get CloudWatch metric statistics'
94+
logger.error('GetMetricStatistics failed', { error: errorMessage })
95+
return NextResponse.json({ error: errorMessage }, { status: 500 })
96+
}
97+
}

0 commit comments

Comments
 (0)