Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 1 addition & 1 deletion apps/docs/content/docs/integrations/cloudwatch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ Get statistics for a CloudWatch metric over a time range
| `startTime` | number | Yes | Start time as Unix epoch seconds |
| `endTime` | number | Yes | End time as Unix epoch seconds |
| `period` | number | Yes | Granularity in seconds \(e.g., 60, 300, 3600\) |
| `statistics` | array | Yes | Statistics to retrieve \(Average, Sum, Minimum, Maximum, SampleCount\) |
| `statistics` | array | No | Statistics to retrieve \(Average, Sum, Minimum, Maximum, SampleCount\). Required unless extendedStatistics is set, which takes precedence |
| `dimensions` | string | No | Dimensions as JSON \(e.g., \{"InstanceId": "i-1234"\}\) |
| `extendedStatistics` | array | No | Percentile statistics to retrieve \(e.g., \["p99", "p95"\]\). AWS accepts either Statistics or ExtendedStatistics, so when set these are requested instead of statistics |
| `unit` | string | No | Only return data reported with this unit \(e.g., Percent, Milliseconds\). Omit to return all units |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,33 @@ import type {
import { defineRouteContract } from '@/lib/api/contracts/types'
import { validateAwsRegion } from '@/lib/core/security/input-validation'

const GetMetricStatisticsSchema = z.object({
region: z
.string()
.min(1, 'AWS region is required')
.refine((v) => validateAwsRegion(v).isValid, {
message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)',
}),
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.coerce.number().int(),
endTime: z.coerce.number().int(),
period: z.coerce.number().int().min(1),
statistics: z
.array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount']))
.min(1, 'At least one statistic is required')
.max(5, 'At most 5 statistics are allowed per GetMetricStatistics call'),
dimensions: z.string().optional(),
extendedStatistics: z.array(z.string().min(1)).max(10).optional(),
unit: z.string().optional(),
})
const GetMetricStatisticsSchema = z
.object({
region: z
.string()
.min(1, 'AWS region is required')
.refine((v) => validateAwsRegion(v).isValid, {
message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)',
}),
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.coerce.number().int(),
endTime: z.coerce.number().int(),
period: z.coerce.number().int().min(1),
statistics: z
.array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount']))
.max(5, 'At most 5 statistics are allowed per GetMetricStatistics call')
.optional(),
dimensions: z.string().optional(),
extendedStatistics: z.array(z.string().min(1)).max(10).optional(),
unit: z.string().optional(),
})
.refine((body) => Boolean(body.statistics?.length || body.extendedStatistics?.length), {
message: 'Provide statistics or extendedStatistics',
path: ['statistics'],
})

const GetMetricStatisticsResponseSchema = z.object({
success: z.literal(true),
Expand Down
66 changes: 66 additions & 0 deletions apps/sim/lib/internal/dynamodb/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ function clientReturning(output: Record<string, unknown>) {
return client
}

/** A client that records the marshalled request input DynamoDB would receive. */
function clientCapturing(onInput: (input: Record<string, unknown>) => void) {
const client = createDynamoDBClient({
region: 'us-east-1',
accessKeyId: 'AKIAEXAMPLE',
secretAccessKey: 'secret',
})
client.middlewareStack.add(
() => async (args) => {
onInput(args.input as Record<string, unknown>)
return { output: { $metadata: {}, Items: [], Count: 0 }, response: {} }
},
{ step: 'build' }
)
return client
}

describe('DynamoDB item unmarshalling', () => {
it('returns JSON-serializable items for sets, binary and integers beyond 2^53', async () => {
const client = clientReturning({
Expand All @@ -27,6 +44,7 @@ describe('DynamoDB item unmarshalling', () => {
avatar: { B: new Uint8Array([104, 105]) },
snowflake: { N: '1234567890123456789' },
age: { N: '42' },
ratio: { N: '0.12345678901234567890123' },
},
],
})
Expand All @@ -44,11 +62,59 @@ describe('DynamoDB item unmarshalling', () => {
avatar: 'aGk=',
snowflake: '1234567890123456789',
age: 42,
ratio: 0.12345678901234568,
},
],
count: 1,
scannedCount: 3,
})
client.destroy()
})

it('round-trips large-number and binary keys through the pagination cursor', async () => {
const pageClient = clientReturning({
Count: 0,
Items: [],
LastEvaluatedKey: {
pk: { S: 'USER#1' },
sk: { N: '1234567890123456789' },
bin: { B: new Uint8Array([104, 105]) },
age: { N: '42' },
ratio: { N: '0.12345678901234567890123' },
nearOne: { N: '1.0000000000000000001' },
},
})
const firstPage = await queryItems(pageClient, 'Users', 'pk = :pk', {
expressionAttributeValues: { ':pk': 'USER#1' },
})
const cursor = await Response.json(firstPage.lastEvaluatedKey).json()
expect(cursor).toEqual({
pk: 'USER#1',
sk: { N: '1234567890123456789' },
bin: { B: 'aGk=' },
age: 42,
ratio: { N: '0.12345678901234567890123' },
nearOne: { N: '1.0000000000000000001' },
})

let sentStartKey: unknown
const client = clientCapturing((input) => {
sentStartKey = input.ExclusiveStartKey
})
await queryItems(client, 'Users', 'pk = :pk', {
expressionAttributeValues: { ':pk': 'USER#1' },
exclusiveStartKey: cursor,
})

expect(sentStartKey).toEqual({
pk: { S: 'USER#1' },
sk: { N: '1234567890123456789' },
bin: { B: new Uint8Array([104, 105]) },
age: { N: '42' },
ratio: { N: '0.12345678901234567890123' },
nearOne: { N: '1.0000000000000000001' },
})
pageClient.destroy()
client.destroy()
})
})
78 changes: 66 additions & 12 deletions apps/sim/lib/internal/dynamodb/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,52 @@ import {
DeleteCommand,
DynamoDBDocumentClient,
GetCommand,
NumberValue,
PutCommand,
QueryCommand,
ScanCommand,
UpdateCommand,
} from '@aws-sdk/lib-dynamodb'
import { isRecordLike } from '@sim/utils/object'
import { awsClientConfig } from '@/lib/internal/aws/credentials'
import type { DynamoDBConnectionConfig, DynamoDBTableSchema } from '@/tools/dynamodb/types'

type DynamoDBReturnValues = 'NONE' | 'ALL_OLD' | 'UPDATED_OLD' | 'ALL_NEW' | 'UPDATED_NEW'

/**
* Keeps integers beyond `Number.MAX_SAFE_INTEGER` (and other values a JS number cannot hold)
* as their exact DynamoDB string, since the SDK default turns them into a `BigInt` that the
* JSON response cannot serialize.
* Keeps any DynamoDB number a JS number cannot hold exactly (integers beyond
* `Number.MAX_SAFE_INTEGER`, high-precision decimals, non-finite values) as a `NumberValue`
* with the exact string, so pagination cursors can round-trip it. The SDK default would turn
* large integers into a `BigInt` that the JSON response cannot serialize.
*/
function unwrapNumber(value: string): number | string {
function unwrapNumber(value: string): number | NumberValue {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return value
if (Number.isInteger(parsed) && !Number.isSafeInteger(parsed)) return value
const exact = Number.isFinite(parsed) && String(parsed) === value
if (exact && (!Number.isInteger(parsed) || Number.isSafeInteger(parsed))) return parsed
return NumberValue.from(value)
}

/**
* Item output for a number kept as a `NumberValue`: the exact string for integers beyond
* `Number.MAX_SAFE_INTEGER` and non-finite values, otherwise the nearest JS number.
*/
function numberValueToJson(value: NumberValue): number | string {
const exact = value.toString()
const parsed = Number(exact)
if (!Number.isFinite(parsed) || (Number.isInteger(parsed) && !Number.isSafeInteger(parsed))) {
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
return exact
}
return parsed
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
}

/**
* Converts unmarshalled DynamoDB values into JSON-serializable shapes: string, number and
* binary sets become arrays, and binary values become base64 strings. Without this a set
* serializes as `{}` and a binary value as an object of byte indexes.
* binary sets become arrays, binary values become base64 strings, and numbers kept as a
* `NumberValue` become a JS number or, when one cannot hold them, their exact string. Without this a set serializes as `{}` and a binary
* value as an object of byte indexes.
*/
function toJsonSafe(value: unknown): unknown {
if (value instanceof NumberValue) return numberValueToJson(value)
if (value instanceof Set) return Array.from(value, toJsonSafe)
if (value instanceof Uint8Array) return Buffer.from(value).toString('base64')
if (Array.isArray(value)) return value.map(toJsonSafe)
Expand All @@ -46,6 +64,42 @@ function toJsonSafeRecord(
return value ? (toJsonSafe(value) as Record<string, unknown>) : undefined
}

/**
* Encodes a `LastEvaluatedKey` so it can be passed back as `exclusiveStartKey`. Key
* attributes are always scalar (S, N or B), so strings and exact numbers stay as-is while a
* number a JS number cannot hold exactly becomes `{ N }` and a binary key becomes `{ B }` (base64):
* plain strings would otherwise be sent back as S and fail the key type check.
*/
function toCursor(key: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
if (!key) return undefined
return Object.fromEntries(
Object.entries(key).map(([name, value]) => {
if (value instanceof NumberValue) return [name, { N: value.toString() }]
if (value instanceof Uint8Array) return [name, { B: toJsonSafe(value) }]
return [name, value]
})
)
}

const DYNAMODB_NUMBER = /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/

/** Restores the `{ N }` and `{ B }` cursor values written by {@link toCursor}. */
function fromCursor(key: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(key).map(([name, value]) => {
if (isRecordLike(value) && Object.keys(value).length === 1) {
if (typeof value.N === 'string' && DYNAMODB_NUMBER.test(value.N)) {
return [name, NumberValue.from(value.N)]
}
if (typeof value.B === 'string') {
return [name, new Uint8Array(Buffer.from(value.B, 'base64'))]
}
}
return [name, value]
})
)
}

export function createDynamoDBClient(config: DynamoDBConnectionConfig): DynamoDBDocumentClient {
const client = new DynamoDBClient({
...awsClientConfig(config.region, config.accessKeyId, config.secretAccessKey),
Expand Down Expand Up @@ -152,7 +206,7 @@ export async function queryItems(
}),
...(options?.indexName && { IndexName: options.indexName }),
...(options?.limit && { Limit: options.limit }),
...(options?.exclusiveStartKey && { ExclusiveStartKey: options.exclusiveStartKey }),
...(options?.exclusiveStartKey && { ExclusiveStartKey: fromCursor(options.exclusiveStartKey) }),
...(options?.scanIndexForward !== undefined && { ScanIndexForward: options.scanIndexForward }),
...(options?.projectionExpression && { ProjectionExpression: options.projectionExpression }),
...(options?.consistentRead !== undefined && { ConsistentRead: options.consistentRead }),
Expand All @@ -163,7 +217,7 @@ export async function queryItems(
items: (response.Items ?? []).map((item) => toJsonSafeRecord(item) ?? {}),
count: response.Count || 0,
scannedCount: response.ScannedCount ?? null,
lastEvaluatedKey: toJsonSafeRecord(response.LastEvaluatedKey),
lastEvaluatedKey: toCursor(response.LastEvaluatedKey),
}
}

Expand Down Expand Up @@ -200,7 +254,7 @@ export async function scanItems(
ExpressionAttributeValues: options.expressionAttributeValues,
}),
...(options?.limit && { Limit: options.limit }),
...(options?.exclusiveStartKey && { ExclusiveStartKey: options.exclusiveStartKey }),
...(options?.exclusiveStartKey && { ExclusiveStartKey: fromCursor(options.exclusiveStartKey) }),
...(options?.indexName && { IndexName: options.indexName }),
...(options?.consistentRead !== undefined && { ConsistentRead: options.consistentRead }),
...(options?.segment !== undefined && { Segment: options.segment }),
Expand All @@ -212,7 +266,7 @@ export async function scanItems(
items: (response.Items ?? []).map((item) => toJsonSafeRecord(item) ?? {}),
count: response.Count || 0,
scannedCount: response.ScannedCount ?? null,
lastEvaluatedKey: toJsonSafeRecord(response.LastEvaluatedKey),
lastEvaluatedKey: toCursor(response.LastEvaluatedKey),
}
}

Expand Down
42 changes: 42 additions & 0 deletions apps/sim/tools/cloudwatch/get_metric_statistics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import { awsCloudwatchGetMetricStatisticsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics'
import { getMetricStatisticsTool } from '@/tools/cloudwatch/get_metric_statistics'
import { validateRequiredParametersAfterMerge } from '@/tools/utils'

const PARAMS = {
awsRegion: 'us-east-1',
awsAccessKeyId: 'AKIAEXAMPLE',
awsSecretAccessKey: 'secret',
namespace: 'AWS/EC2',
metricName: 'CPUUtilization',
startTime: 1700000000,
endTime: 1700003600,
period: 300,
}

function parseRequest(params: Record<string, unknown>) {
const body = getMetricStatisticsTool.operation.input(params as never)
return awsCloudwatchGetMetricStatisticsContract.body?.safeParse(body)
}

describe('cloudwatch_get_metric_statistics', () => {
it('accepts percentile statistics without a standard statistic', () => {
const params = { ...PARAMS, extendedStatistics: ['p99', 'p95'] }
expect(() =>
validateRequiredParametersAfterMerge(
'cloudwatch_get_metric_statistics',
getMetricStatisticsTool,
params
)
).not.toThrow()
expect(parseRequest(params)?.success).toBe(true)
})

it('rejects a request with neither standard nor percentile statistics', () => {
const result = parseRequest(PARAMS)
expect(result?.success).toBe(false)
expect(result?.error?.issues.map((issue) => issue.message)).toEqual([
'Provide statistics or extendedStatistics',
])
})
})
7 changes: 4 additions & 3 deletions apps/sim/tools/cloudwatch/get_metric_statistics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,10 @@ export const getMetricStatisticsTool: InternalToolConfig<
},
statistics: {
type: 'array',
required: true,
required: false,
visibility: 'user-or-llm',
description: 'Statistics to retrieve (Average, Sum, Minimum, Maximum, SampleCount)',
description:
'Statistics to retrieve (Average, Sum, Minimum, Maximum, SampleCount). Required unless extendedStatistics is set, which takes precedence',
},
dimensions: {
type: 'string',
Expand Down Expand Up @@ -102,7 +103,7 @@ export const getMetricStatisticsTool: InternalToolConfig<
startTime: params.startTime,
endTime: params.endTime,
period: params.period,
statistics: params.statistics,
...(params.statistics && params.statistics.length > 0 && { statistics: params.statistics }),
...(params.dimensions && { dimensions: params.dimensions }),
...(params.extendedStatistics &&
params.extendedStatistics.length > 0 && {
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/cloudwatch/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export interface CloudWatchGetMetricStatisticsParams extends CloudWatchConnectio
startTime: number
endTime: number
period: number
statistics: string[]
statistics?: string[]
dimensions?: string
extendedStatistics?: string[]
unit?: string
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/tools/generated/tool-metadata.ts

Large diffs are not rendered by default.

Loading