Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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>
  • Loading branch information
TheodoreSpeaks and TheodoreSpeaks authored Apr 3, 2026
commit d290e06eb3aaceb947ba003c8b36ea55303bc233
96 changes: 96 additions & 0 deletions apps/sim/app/api/tools/cloudwatch/describe-alarms/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {
type AlarmType,
CloudWatchClient,
DescribeAlarmsCommand,
type StateValue,
} from '@aws-sdk/client-cloudwatch'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'

const logger = createLogger('CloudWatchDescribeAlarms')

const DescribeAlarmsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
alarmNamePrefix: z.string().optional(),
stateValue: z.preprocess(
(v) => (v === '' ? undefined : v),
z.enum(['OK', 'ALARM', 'INSUFFICIENT_DATA']).optional()
),
alarmType: z.preprocess(
(v) => (v === '' ? undefined : v),
z.enum(['MetricAlarm', 'CompositeAlarm']).optional()
),
limit: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().positive().optional()
),
})

export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}

const body = await request.json()
const validatedData = DescribeAlarmsSchema.parse(body)

const client = new CloudWatchClient({
region: validatedData.region,
credentials: {
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
},
})

const command = new DescribeAlarmsCommand({
...(validatedData.alarmNamePrefix && { AlarmNamePrefix: validatedData.alarmNamePrefix }),
...(validatedData.stateValue && { StateValue: validatedData.stateValue as StateValue }),
...(validatedData.alarmType && { AlarmTypes: [validatedData.alarmType as AlarmType] }),
...(validatedData.limit !== undefined && { MaxRecords: validatedData.limit }),
})

const response = await client.send(command)

const metricAlarms = (response.MetricAlarms ?? []).map((a) => ({
alarmName: a.AlarmName ?? '',
alarmArn: a.AlarmArn ?? '',
stateValue: a.StateValue ?? 'UNKNOWN',
stateReason: a.StateReason ?? '',
metricName: a.MetricName,
namespace: a.Namespace,
comparisonOperator: a.ComparisonOperator,
threshold: a.Threshold,
evaluationPeriods: a.EvaluationPeriods,
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
}))

const compositeAlarms = (response.CompositeAlarms ?? []).map((a) => ({
alarmName: a.AlarmName ?? '',
alarmArn: a.AlarmArn ?? '',
stateValue: a.StateValue ?? 'UNKNOWN',
stateReason: a.StateReason ?? '',
metricName: undefined,
namespace: undefined,
comparisonOperator: undefined,
threshold: undefined,
evaluationPeriods: undefined,
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
}))

return NextResponse.json({
success: true,
output: { alarms: [...metricAlarms, ...compositeAlarms] },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe CloudWatch alarms'
logger.error('DescribeAlarms failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}
62 changes: 62 additions & 0 deletions apps/sim/app/api/tools/cloudwatch/describe-log-groups/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils'

const logger = createLogger('CloudWatchDescribeLogGroups')

const DescribeLogGroupsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
prefix: z.string().optional(),
limit: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().positive().optional()
),
})

export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}

const body = await request.json()
const validatedData = DescribeLogGroupsSchema.parse(body)

const client = createCloudWatchLogsClient({
region: validatedData.region,
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
})

const command = new DescribeLogGroupsCommand({
...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }),
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
})

const response = await client.send(command)

const logGroups = (response.logGroups ?? []).map((lg) => ({
logGroupName: lg.logGroupName ?? '',
arn: lg.arn ?? '',
storedBytes: lg.storedBytes ?? 0,
retentionInDays: lg.retentionInDays,
creationTime: lg.creationTime,
}))

return NextResponse.json({
success: true,
output: { logGroups },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe CloudWatch log groups'
logger.error('DescribeLogGroups failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}
53 changes: 53 additions & 0 deletions apps/sim/app/api/tools/cloudwatch/describe-log-streams/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'

const logger = createLogger('CloudWatchDescribeLogStreams')

import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils'

Comment on lines +5 to +9
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Import after const declaration

Same pattern as query-logs/route.ts: the import on line 9 appears after the const logger declaration on line 6. All imports should precede any executable statements.

Suggested change
const logger = createLogger('CloudWatchDescribeLogStreams')
import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils'
const logger = createLogger('CloudWatchDescribeLogStreams')

const DescribeLogStreamsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
logGroupName: z.string().min(1, 'Log group name is required'),
prefix: z.string().optional(),
limit: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().positive().optional()
),
})

export async function POST(request: NextRequest) {
try {
const auth = await checkSessionOrInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}

const body = await request.json()
const validatedData = DescribeLogStreamsSchema.parse(body)

const client = createCloudWatchLogsClient({
region: validatedData.region,
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
})

const result = await describeLogStreams(client, validatedData.logGroupName, {
prefix: validatedData.prefix,
limit: validatedData.limit,
})

return NextResponse.json({
success: true,
output: { logStreams: result.logStreams },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to describe CloudWatch log streams'
logger.error('DescribeLogStreams failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}
61 changes: 61 additions & 0 deletions apps/sim/app/api/tools/cloudwatch/get-log-events/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'

const logger = createLogger('CloudWatchGetLogEvents')

import { createCloudWatchLogsClient, getLogEvents } from '@/app/api/tools/cloudwatch/utils'

const GetLogEventsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
logGroupName: z.string().min(1, 'Log group name is required'),
logStreamName: z.string().min(1, 'Log stream name is required'),
startTime: z.number({ coerce: true }).int().optional(),
endTime: z.number({ coerce: true }).int().optional(),
limit: z.preprocess(
(v) => (v === '' || v === undefined || v === null ? undefined : v),
z.number({ coerce: true }).int().positive().optional()
),
})

export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}

const body = await request.json()
const validatedData = GetLogEventsSchema.parse(body)

const client = createCloudWatchLogsClient({
region: validatedData.region,
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
})

const result = await getLogEvents(
client,
validatedData.logGroupName,
validatedData.logStreamName,
{
startTime: validatedData.startTime,
endTime: validatedData.endTime,
limit: validatedData.limit,
}
)

return NextResponse.json({
success: true,
output: { events: result.events },
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to get CloudWatch log events'
logger.error('GetLogEvents failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}
97 changes: 97 additions & 0 deletions apps/sim/app/api/tools/cloudwatch/get-metric-statistics/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cloudwatch'
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { checkInternalAuth } from '@/lib/auth/hybrid'

const logger = createLogger('CloudWatchGetMetricStatistics')

const GetMetricStatisticsSchema = z.object({
region: z.string().min(1, 'AWS region is required'),
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
namespace: z.string().min(1, 'Namespace is required'),
metricName: z.string().min(1, 'Metric name is required'),
startTime: z.number({ coerce: true }).int(),
endTime: z.number({ coerce: true }).int(),
period: z.number({ coerce: true }).int().min(1),
statistics: z.array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])).min(1),
dimensions: z.string().optional(),
})

export async function POST(request: NextRequest) {
try {
const auth = await checkInternalAuth(request)
if (!auth.success || !auth.userId) {
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
}

const body = await request.json()
const validatedData = GetMetricStatisticsSchema.parse(body)

const client = new CloudWatchClient({
region: validatedData.region,
credentials: {
accessKeyId: validatedData.accessKeyId,
secretAccessKey: validatedData.secretAccessKey,
},
})

let parsedDimensions: { Name: string; Value: string }[] | undefined
if (validatedData.dimensions) {
try {
const dims = JSON.parse(validatedData.dimensions)
if (Array.isArray(dims)) {
parsedDimensions = dims.map((d: Record<string, string>) => ({
Name: d.name,
Value: d.value,
}))
} else if (typeof dims === 'object') {
parsedDimensions = Object.entries(dims).map(([name, value]) => ({
Name: name,
Value: String(value),
}))
}
} catch {
throw new Error('Invalid dimensions JSON')
}
}

const command = new GetMetricStatisticsCommand({
Namespace: validatedData.namespace,
MetricName: validatedData.metricName,
StartTime: new Date(validatedData.startTime * 1000),
EndTime: new Date(validatedData.endTime * 1000),
Period: validatedData.period,
Statistics: validatedData.statistics,
...(parsedDimensions && { Dimensions: parsedDimensions }),
})

const response = await client.send(command)

const datapoints = (response.Datapoints ?? [])
.sort((a, b) => (a.Timestamp?.getTime() ?? 0) - (b.Timestamp?.getTime() ?? 0))
.map((dp) => ({
timestamp: dp.Timestamp ? Math.floor(dp.Timestamp.getTime() / 1000) : 0,
average: dp.Average,
sum: dp.Sum,
minimum: dp.Minimum,
maximum: dp.Maximum,
sampleCount: dp.SampleCount,
unit: dp.Unit,
}))

return NextResponse.json({
success: true,
output: {
label: response.Label ?? validatedData.metricName,
datapoints,
},
})
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Failed to get CloudWatch metric statistics'
logger.error('GetMetricStatistics failed', { error: errorMessage })
return NextResponse.json({ error: errorMessage }, { status: 500 })
}
}
Loading
Loading