Skip to content

Simulated CloudWatch Metrics

Yulin simulates CloudWatch metrics and alarms in memory. Application code can publish datapoints and read their statistics through the normal CloudWatch commands. Alarms evaluate on simulated time and can notify simulated SNS topics.

Simulated Lambda publishes AWS/Lambda metrics, and simulated Cognito publishes AWS/Cognito metrics. Tests can seed other AWS-managed metrics through the service writer described below.

A custom metric’s datapoints arrive either from PutMetricData or from a CloudWatch Logs metric filter counting matching log events. See the CloudWatch Logs docs for the second route.

CloudWatch specific types are imported from the @kensio/yulin/cloudwatch subpath.

A metric is identified by its namespace, name, and exact set of dimensions. Publish a value and read it back over a period:

/**
* Publishing a custom metric and reading it back as statistics.
*/
import {
GetMetricStatisticsCommand,
PutMetricDataCommand,
} from "@aws-sdk/client-cloudwatch";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const metrics = simAws.cloudWatch();
await metrics.putMetricData(
new PutMetricDataCommand({
Namespace: "Orders",
MetricData: [
{
MetricName: "Failed",
Value: 1,
Unit: "Count",
Timestamp: new Date("2026-08-16T09:00:10.000Z"),
Dimensions: [{ Name: "Channel", Value: "web" }],
},
],
}),
);
const read = await metrics.getMetricStatistics(
new GetMetricStatisticsCommand({
Namespace: "Orders",
MetricName: "Failed",
Dimensions: [{ Name: "Channel", Value: "web" }],
StartTime: new Date("2026-08-16T09:00:00.000Z"),
EndTime: new Date("2026-08-16T09:05:00.000Z"),
Period: 60,
Statistics: ["Sum", "SampleCount"],
}),
);
// One datapoint, stamped with the start of the minute the value fell in.
console.log(read.Datapoints?.at(0)?.Sum);

A datum may state its values as a plain Value, as a StatisticValues summary, or as Values with matching Counts. All three answer the same statistics, and a metric published one way reads back like a metric published another.

Values are checked the way real CloudWatch checks them. A value sits within -2^360 to 2^360, is never NaN or an infinity, runs to at most 150 unique values in one datum, and carries Counts only alongside the Values it counts. Unit is the closed StandardUnit set on the way in and on the way out. A query naming a unit CloudWatch lacks fails here as it would in an account.

Metrics are identified by their dimensions

Section titled “Metrics are identified by their dimensions”

CloudWatch treats each exact set of dimensions as a separate metric. Publishing the same metric name with two dimension values creates two metrics. A query without dimensions reads only datapoints published without dimensions.

A datum without a Timestamp uses the simulation’s clock. Set or advance the clock to place datapoints in exact periods:

/**
* Publishing metrics across simulated minutes, and reading a value per minute.
*/
import {
GetMetricDataCommand,
PutMetricDataCommand,
} from "@aws-sdk/client-cloudwatch";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const metrics = simAws.cloudWatch();
const startedAt = new Date("2026-08-16T09:00:00.000Z");
await simAws.clock().setTo(startedAt);
// Three failures, one a minute, without waiting three real minutes.
for (let minute = 0; minute < 3; minute++) {
await metrics.putMetricData(
new PutMetricDataCommand({
Namespace: "Orders",
MetricData: [{ MetricName: "Failed", Value: 1 }],
}),
);
await simAws.clock().advanceBy({ minutes: 1 });
}
const read = await metrics.getMetricData(
new GetMetricDataCommand({
MetricDataQueries: [
{
Id: "failed",
MetricStat: {
Metric: { Namespace: "Orders", MetricName: "Failed" },
Period: 60,
Stat: "Sum",
},
},
],
StartTime: startedAt,
EndTime: new Date("2026-08-16T09:03:00.000Z"),
ScanBy: "TimestampAscending",
}),
);
// [1, 1, 1]: one failure in each of the three simulated minutes.
console.log(read.MetricDataResults?.at(0)?.Values);

ListMetrics reads RecentlyActive: "PT3H" against the same clock, so advancing time past the window drops a metric out of the listing without anything having to expire it.

Callers publish custom metrics through PutMetricData. AWS-managed metrics use namespaces beginning with AWS/, which callers cannot publish into.

Simulated Cognito publishes counts under AWS/Cognito, dimensioned by UserPool and UserPoolClient. See the Cognito docs.

Simulated Lambda publishes Invocations, Duration, and Errors under AWS/Lambda, dimensioned by FunctionName. These metrics require no extra configuration or execution-role permission.

/**
* An alarm firing on the errors a failing function counted.
*/
import {
DescribeAlarmsCommand,
PutMetricAlarmCommand,
} from "@aws-sdk/client-cloudwatch";
import { CreateFunctionCommand, InvokeCommand } from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";
import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
const simAws = new SimAws();
await simAws.clock().setTo(new Date("2026-08-30T09:00:00Z"));
await simAws.lambda().createFunction(
new CreateFunctionCommand({
FunctionName: "orders",
Role: `arn:aws:iam::${simAws.defaultAccountId}:role/OrdersRole`,
Code: {
ZipFile: makeLambdaZipFileInput(() => {
throw new Error("order has no items");
}),
},
}),
);
await simAws.backgroundTasksComplete();
await simAws.cloudWatch().putMetricAlarm(
new PutMetricAlarmCommand({
AlarmName: "OrdersFailing",
Namespace: "AWS/Lambda",
MetricName: "Errors",
Dimensions: [{ Name: "FunctionName", Value: "orders" }],
Statistic: "Sum",
Period: 300,
EvaluationPeriods: 3,
DatapointsToAlarm: 1,
Threshold: 0,
ComparisonOperator: "GreaterThanThreshold",
TreatMissingData: "notBreaching",
}),
);
await simAws.lambda().invoke(new InvokeCommand({ FunctionName: "orders" }));
await simAws.backgroundTasksComplete();
await simAws.clock().advanceBy({ minutes: 6 });
const { MetricAlarms } = await simAws
.cloudWatch()
.describeAlarms(new DescribeAlarmsCommand({ AlarmNames: ["OrdersFailing"] }));
// ALARM. The invocation counted its own error.
console.log(MetricAlarms?.[0]?.StateValue);

Duration uses the simulation’s clock rather than the host’s. A handler that advances the clock reports that elapsed time, while a handler that leaves it unchanged reports zero duration. IteratorAge uses the same clock. The Lambda documentation describes the value reported by a stream event source mapping.

PutMetricData refuses namespaces beginning with AWS/. To test an alarm for another AWS-managed metric, add the datapoint through cloudWatch().serviceWriter().

/**
* Driving an alarm on a metric nothing in the simulation publishes.
*/
import {
DescribeAlarmsCommand,
PutMetricAlarmCommand,
} from "@aws-sdk/client-cloudwatch";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
await simAws.clock().setTo(new Date("2026-08-30T09:00:00Z"));
await simAws.cloudWatch().putMetricAlarm(
new PutMetricAlarmCommand({
AlarmName: "SignInsThrottling",
Namespace: "AWS/Cognito",
MetricName: "SignInThrottles",
Dimensions: [{ Name: "UserPool", Value: "eu-west-1_pool" }],
Statistic: "Sum",
Period: 300,
EvaluationPeriods: 3,
DatapointsToAlarm: 1,
Threshold: 0,
ComparisonOperator: "GreaterThanThreshold",
TreatMissingData: "notBreaching",
}),
);
// The pool would have published this. Nothing here does, so the test does.
simAws
.cloudWatch()
.serviceWriter()
.publish([
{
namespace: "AWS/Cognito",
metricName: "SignInThrottles",
dimensions: [{ Name: "UserPool", Value: "eu-west-1_pool" }],
value: 4,
unit: "Count",
},
]);
await simAws.backgroundTasksComplete();
await simAws.clock().advanceBy({ minutes: 6 });
const { MetricAlarms } = await simAws
.cloudWatch()
.describeAlarms(
new DescribeAlarmsCommand({ AlarmNames: ["SignInsThrottling"] }),
);
// ALARM.
console.log(MetricAlarms?.[0]?.StateValue);

A datapoint arriving without a timestamp is stamped with the simulation’s clock. One carrying its own lands where it says, which fills a window without the clock having to be walked through it.

The service writer is a test setup API. PutMetricData keeps its reserved-namespace validation.

An alarm watches one metric and changes state on the simulation’s clock. Each evaluation is scheduled at the next period boundary. Advancing time by twenty minutes runs twenty one-minute evaluations and settles before the next line of the test.

/**
* An alarm that fires into an SNS topic once two of three minutes breach.
*/
import {
DescribeAlarmsCommand,
PutMetricAlarmCommand,
PutMetricDataCommand,
} from "@aws-sdk/client-cloudwatch";
import { CreateTopicCommand } from "@aws-sdk/client-sns";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const metrics = simAws.cloudWatch();
await simAws.clock().setTo(new Date("2026-08-16T09:00:00.000Z"));
const topic = await simAws
.sns()
.createTopic(new CreateTopicCommand({ Name: "orders-alerts" }));
await metrics.putMetricAlarm(
new PutMetricAlarmCommand({
AlarmName: "OrdersFailing",
Namespace: "Orders",
MetricName: "Failed",
Statistic: "Sum",
Period: 60,
EvaluationPeriods: 3,
DatapointsToAlarm: 2,
Threshold: 5,
ComparisonOperator: "GreaterThanThreshold",
AlarmActions: [String(topic.TopicArn)],
}),
);
// Two breaching minutes, without waiting two real minutes.
for (let minute = 0; minute < 2; minute++) {
await metrics.putMetricData(
new PutMetricDataCommand({
Namespace: "Orders",
MetricData: [{ MetricName: "Failed", Value: 10 }],
}),
);
await simAws.clock().advanceBy({ minutes: 1 });
}
const described = await metrics.describeAlarms(
new DescribeAlarmsCommand({ AlarmNames: ["OrdersFailing"] }),
);
// "ALARM", and anything subscribed to the topic has the notification.
console.log(described.MetricAlarms?.at(0)?.StateValue);

A new alarm is in INSUFFICIENT_DATA until it has evaluated a period, as on real CloudWatch. The window it looks back over reaches behind the moment the alarm was created. An alarm over an empty metric, with TreatMissingData: "breaching", therefore fires on its first evaluation, without waiting for the periods to accumulate. That is what an account does too.

An alarm notifies through the ordinary Publish path. A notification fans out to the topic’s subscriptions exactly as an SDK caller’s message would, with the JSON body real CloudWatch sends: AlarmName, NewStateValue, OldStateValue, NewStateReason, StateChangeTime and Trigger. Only a change fires anything, and an alarm that stays in ALARM across ten periods notifies once.

SetAlarmState forces a transition and fires its actions. That is how a test exercises a subscriber without arranging for a metric to breach at all.

The topic has to be in the same account and region as the alarm, as real CloudWatch requires. An action that lands nowhere is recorded, never passed over quietly:

/**
* Finding out that an alarm action reached nothing.
*/
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
// ...after an alarm with a bad action ARN has fired:
for (const failure of simAws.cloudWatch().alarmActionFailures) {
console.log(failure.alarmName, failure.actionArn, failure.reason);
}

Real CloudWatch tells nobody when an alarm action fails, and this tells nobody either. The alarm changes state regardless. Keeping the failure is what stops a subscriber’s queue being mysteriously empty.

  • The four threshold comparison operators, DatapointsToAlarm for M-of-N evaluation, and all four TreatMissingData treatments including ignore, which leaves the alarm where it is.
  • ActionsEnabled: false still evaluates and records state. It just publishes no notification.
  • DescribeAlarmHistory reports the state changes with the simulated time each happened at.
  • An SNS topic ARN is the only action target. Auto Scaling, EC2, Systems Manager and Lambda actions are refused, never stored and ignored, because an alarm that fired into nowhere would let a test pass while the thing the alarm exists to do never happened.
  • Composite alarms, anomaly detection and metric math alarms are all refused.

Alarms are nearly always declared in infrastructure rather than created through the SDK, so AWS::CloudWatch::Alarm is deployed by simulated CloudFormation. The alarm a stack creates is the same thing PutMetricAlarm creates. It evaluates on the clock, fires on a transition, and refuses what the command refuses.

OrdersFailing:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: OrdersFailing
Namespace: Orders
MetricName: Failed
Statistic: Sum
Period: 60
EvaluationPeriods: 3
DatapointsToAlarm: 2
Threshold: 5
ComparisonOperator: GreaterThanThreshold
AlarmActions:
- !Ref Alerts

Ref resolves to the alarm name and Fn::GetAtt Arn to the alarm ARN. An AlarmActions entry holding a Ref to an AWS::SNS::Topic in the same stack resolves to that topic’s ARN. A test can deploy the stack, publish a breaching datapoint, advance the clock and read the notification off whatever is subscribed. Deleting the stack deletes the alarm and takes its scheduled evaluation back off the clock with it.

AlarmName may be left out, and the alarm is then named after the stack, the logical ID and a tail derived from both. Real CloudFormation ends a physical ID of the same shape in twelve random characters, and the tail here comes from the name it follows, giving one alarm the same name at every deployment. simAws.cloudWatch().allAlarms() hands a test the name to pass to DescribeAlarms, and the CloudFormation docs cover how a long name is trimmed.

These are the properties acted on: AlarmName, AlarmDescription, ActionsEnabled, AlarmActions, OKActions, InsufficientDataActions, Namespace, MetricName, Dimensions, Statistic, Unit, Period, EvaluationPeriods, DatapointsToAlarm, Threshold, ComparisonOperator and TreatMissingData.

Metrics, ThresholdMetricId, ExtendedStatistic and EvaluateLowSampleCountPercentile are refused, in the same words PutMetricAlarm refuses them with. Each of them changes what the alarm watches or how it decides. An alarm deployed with one ignored would sit in a test looking configured and evaluating something else.

Tags is the one difference from the command, which refuses it outright. Real CloudFormation tags the alarm it creates, and this leaves the alarm untagged. A template’s tags are usually the whole stack’s rather than the alarm’s, and they are recorded as an ignored property, leaving the deploy standing. Nothing reads them back either. An alarm deployed with tags behaves as though the template had never named them.

AWS::CloudWatch::CompositeAlarm, AWS::CloudWatch::Dashboard and AWS::CloudWatch::AnomalyDetector are left undeployed, and recorded as gaps in the stack.

CloudWatch metrics have no ARN, so every metric action uses a resource of *. A policy written against a fabricated metric ARN such as arn:aws:cloudwatch:eu-west-2:111111111111:metric/Orders/Failed is invalid in Yulin and AWS.

Alarms are the exception, and do have an ARN. PutMetricAlarm, DeleteAlarms and SetAlarmState authorize against arn:aws:cloudwatch:<region>:<account>:alarm:<name>, while DescribeAlarms and DescribeAlarmHistory take no resource-level permission at all, exactly as on real CloudWatch.

The one way to narrow publishing is the cloudwatch:namespace condition key:

/**
* A simulated IAM policy allowing a Role to publish into one namespace only.
*/
import { PutMetricDataCommand } from "@aws-sdk/client-cloudwatch";
import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();
const role = await simAws.iam().createRole(
new CreateRoleCommand({
RoleName: "OrdersFunctionRole",
AssumeRolePolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Principal: { Service: "lambda.amazonaws.com" },
Action: "sts:AssumeRole",
},
}),
}),
);
await simAws.iam().putRolePolicy(
new PutRolePolicyCommand({
RoleName: "OrdersFunctionRole",
PolicyName: "PublishOrdersMetrics",
PolicyDocument: JSON.stringify({
Version: "2012-10-17",
Statement: {
Effect: "Allow",
Action: "cloudwatch:PutMetricData",
// Metrics have no ARN, so the namespace condition is what scopes this.
Resource: "*",
Condition: { StringEquals: { "cloudwatch:namespace": "Orders" } },
},
}),
}),
);
const asRole = { caller: { kind: "arn", arn: role.Role.Arn } } as const;
await simAws.cloudWatch().putMetricData(
new PutMetricDataCommand({
Namespace: "Orders",
MetricData: [{ MetricName: "Failed", Value: 1 }],
}),
asRole,
);
// Publishing into any other namespace as this Role is denied.
  • PutMetricData, with Value, StatisticValues and Values/Counts.
  • ListMetrics, filtered by namespace, metric name and dimensions, with RecentlyActive and NextToken paging.
  • GetMetricStatistics, with SampleCount, Average, Sum, Minimum and Maximum over periods of a whole number of minutes, filtered by Unit.
  • GetMetricData, with MetricStat queries, ScanBy and ReturnData.
  • PutMetricAlarm, DescribeAlarms, DeleteAlarms, SetAlarmState and DescribeAlarmHistory, with evaluation on the simulation’s clock and SNS notifications on a state change.
  • IAM authorization on each action, including the cloudwatch:namespace condition key and alarm-ARN resources.
  • AWS/Lambda Invocations, Errors, Duration and IteratorAge, published by simulated Lambda itself and read back the way a custom metric is.
  • AWS/Cognito SignInSuccesses, SignUpSuccesses, TokenRefreshSuccesses and FederationSuccesses, published by a simulated user pool.
  • Seeding a metric AWS publishes, through cloudWatch().serviceWriter(), so an alarm on one can be driven to a state change from a test.
  • AWS::CloudWatch::Alarm in simulated CloudFormation, deployed through PutMetricAlarm and taken down with the stack.

Yulin rejects unsupported CloudWatch behavior instead of ignoring it:

  • Composite and anomaly detection alarms. Metrics and ThresholdMetricId on PutMetricAlarm are refused. There is no trained model here for an anomaly band to come from.
  • Metric math. A GetMetricData query carrying an Expression is refused.
  • Percentiles and other extended statistics. They need the individual values behind a period, which a StatisticValues datum never carries, so CloudWatch itself cannot report one for a metric published that way.
  • High-resolution metrics. StorageResolution: 1 is refused. Every period here is a whole number of minutes.
  • MaxDatapoints. Real CloudWatch answers it by widening the period, and every result here comes back at the period its query asked for.
  • Cross-account metrics. IncludeLinkedAccounts and OwningAccount are refused. There is no monitoring account.
  • Most metrics AWS publishes. The AWS/Lambda and AWS/Cognito metrics above are the only ones a simulated service writes. Throttles and ConcurrentExecutions need concurrency limits, which simulated Lambda has none of, and every other AWS/ namespace needs a source event built before a metric can come from one. A test seeds what it needs from one of those through the service writer. A CloudWatch Logs metric filter naming a reserved namespace is refused when it publishes, as PutMetricData refuses a caller naming one.

Two behaviors differ from AWS. Yulin accepts datapoints more than two weeks old or more than two hours in the future, which makes it easier to seed a test window. It also returns datapoints in ascending timestamp order so tests receive deterministic results.