Simulated CloudWatch Logs
Yulin includes a simulated Amazon CloudWatch Logs for tests and local development. It holds log
groups, the streams inside them and the events written to those streams. A test can put log events
and read them back with GetLogEvents, or search them with FilterLogEvents, without an AWS
account.
The point of it is to make log data addressable. Code that writes to CloudWatch Logs is code teams already have, and the alternative for a test is capturing process output.
CloudWatch Logs specific types are imported from the @kensio/yulin/logs subpath.
Writing and searching log events
Section titled “Writing and searching log events”A log group holds streams, a stream holds events, and FilterLogEvents searches across every
stream in a group. A test can therefore name the group and leave the stream out, without knowing
which execution environment wrote the line.
/** * Writing log events to a simulated log group and searching for one. */
import { CreateLogGroupCommand, CreateLogStreamCommand, FilterLogEventsCommand, PutLogEventsCommand,} from "@aws-sdk/client-cloudwatch-logs";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const logs = simAws.logs();
const logGroupName = "/aws/lambda/orders";const logStreamName = "2026/08/16/[$LATEST]0f7c1a";
await logs.createLogGroup(new CreateLogGroupCommand({ logGroupName }));await logs.createLogStream( new CreateLogStreamCommand({ logGroupName, logStreamName }),);
await logs.putLogEvents( new PutLogEventsCommand({ logGroupName, logStreamName, logEvents: [ { timestamp: Date.parse("2026-08-16T09:00:00Z"), message: "INFO handling order-1", }, { timestamp: Date.parse("2026-08-16T09:00:01Z"), message: "ERROR order has no items", }, ], }),);
const found = await logs.filterLogEvents( new FilterLogEventsCommand({ logGroupName, filterPattern: "ERROR" }),);
// One event, from the stream that wrote it.console.log(found.events?.length, found.events?.[0]?.logStreamName);A write needs both the group and the stream to exist already. Real CloudWatch Logs refuses a write
to either one that is absent. A missing logs:CreateLogStream permission therefore shows up as a failure, where
otherwise the logs would quietly never appear.
Filter patterns
Section titled “Filter patterns”The plain text filter pattern syntax is supported. Terms are matched as case sensitive substrings,
every unprefixed term must appear, a - prefix excludes a term, a ? prefix makes a term one of a
set of alternatives, and a quoted phrase matches with its spaces intact.
| Pattern | Matches |
|---|---|
ERROR |
messages containing ERROR |
ERROR orders |
messages containing both terms |
?ERROR ?WARN |
messages containing either term |
ERROR -Throttling |
messages containing ERROR but not Throttling |
"order has no items" |
messages containing that exact phrase |
An omitted or empty pattern matches everything.
The structured pattern syntaxes are refused. A JSON property pattern ({ $.level = "ERROR" }), a
space delimited field pattern ([level=ERROR, message]) and a regular expression term
(%ERROR|WARN%) each raise SimLogsUnsupportedOperationException. Approximating one would be
worse. A pattern quietly treated as matching everything would turn an assertion about one log line
into an assertion about any log line at all, and the test would keep passing while testing nothing.
Reading one stream
Section titled “Reading one stream”GetLogEvents reads a single stream and pages in both directions. With no token it answers with
the newest events, as real CloudWatch Logs does. startFromHead starts at the oldest. Following
nextForwardToken walks towards newer events, and reaching the end gives the same token back. A
caller polling a stream keeps it and asks again.
Both readers narrow to a half open time window. An event whose timestamp equals startTime is
included, and one whose timestamp equals endTime is left out.
A token is an offset into the events the request selected, so keep startTime and endTime the
same across a walk. Changing the window part-way through counts the offset against a different set
of events, and the page comes back as a different page.
/** * Paging through one simulated log stream from the oldest event. */
import { CreateLogGroupCommand, CreateLogStreamCommand, GetLogEventsCommand, PutLogEventsCommand,} from "@aws-sdk/client-cloudwatch-logs";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const logs = simAws.logs();
const logGroupName = "/aws/lambda/orders";const logStreamName = "2026/08/16/[$LATEST]0f7c1a";
await logs.createLogGroup(new CreateLogGroupCommand({ logGroupName }));await logs.createLogStream( new CreateLogStreamCommand({ logGroupName, logStreamName }),);await logs.putLogEvents( new PutLogEventsCommand({ logGroupName, logStreamName, logEvents: [1, 2, 3, 4, 5].map((second) => ({ timestamp: Date.parse("2026-08-16T09:00:00Z") + second * 1000, message: `line ${second}`, })), }),);
let nextToken: string | undefined;const read: string[] = [];
for (;;) { const page = await logs.getLogEvents( new GetLogEventsCommand({ logGroupName, logStreamName, startFromHead: true, limit: 2, nextToken, }), );
if (page.events === undefined || page.events.length === 0) break;
read.push(...page.events.map((event) => event.message)); nextToken = page.nextForwardToken;}
console.log(read);Retention
Section titled “Retention”Retention is held as a property to assert on. Events stay where they are, and seeing one go would mean moving the clock by months. What teams get wrong about retention is the value they deployed, ahead of the deletion that eventually follows from it. A log group with no retention keeps its events forever, the AWS default.
The accepted values are a fixed set. A reasonable-looking retentionInDays: 10 is refused here
exactly as it is by an account.
/** * Asserting on the retention a simulated log group was given. */
import { CreateLogGroupCommand, DescribeLogGroupsCommand, PutRetentionPolicyCommand,} from "@aws-sdk/client-cloudwatch-logs";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const logs = simAws.logs();
const logGroupName = "/aws/lambda/orders";
await logs.createLogGroup(new CreateLogGroupCommand({ logGroupName }));await logs.putRetentionPolicy( new PutRetentionPolicyCommand({ logGroupName, retentionInDays: 14 }),);
const described = await logs.describeLogGroups( new DescribeLogGroupsCommand({ logGroupNamePrefix: "/aws/lambda/" }),);
// 14, and the ARN form a policy is written against.console.log( described.logGroups?.[0]?.retentionInDays, described.logGroups?.[0]?.arn,);Lambda handler output
Section titled “Lambda handler output”A Lambda function’s output is recorded into /aws/lambda/<function name> as it runs, whether its
code is a zip archive or a real in-process handler. A test can then assert on what a handler logged
by searching its log group.
/** * Asserting on what a simulated Lambda handler logged. */
import { FilterLogEventsCommand } from "@aws-sdk/client-cloudwatch-logs";import { CreateFunctionCommand, InvokeCommand } from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";import { makeLambdaCodeZip } from "@kensio/yulin/lambda";
const simAws = new SimAws();
await simAws.lambda().createFunction( new CreateFunctionCommand({ FunctionName: "orders", Role: `arn:aws:iam::${simAws.defaultAccountId}:role/OrdersRole`, Handler: "index.handler", Code: { ZipFile: makeLambdaCodeZip({ "index.js": "exports.handler = async () => {\n" + ' console.error("ERROR order has no items");\n' + "};\n", }), }, }),);
await simAws.backgroundTasksComplete();await simAws.lambda().invoke(new InvokeCommand({ FunctionName: "orders" }));
const found = await simAws.logs().filterLogEvents( new FilterLogEventsCommand({ logGroupName: "/aws/lambda/orders", filterPattern: "ERROR", }),);
console.log(found.events?.[0]?.message);The output still reaches the terminal as well. Real Lambda sends it to CloudWatch Logs and nowhere else, but a test tool that swallowed it would make a failing test harder to debug. Recording is a tee.
Each invocation’s context.logGroupName and context.logStreamName name the group and stream that
were actually written to. Stream names use the real YYYY/MM/DD/[$LATEST]<hash> format. The hash
identifies the execution environment, and one environment serves more than one request. Match the
shape in a test, and leave the value alone.
Writing on this path is unconditional. A real function needs logs:CreateLogGroup and
logs:PutLogEvents on its execution Role, and one without them produces no logs at all, in silence.
Simulating that would leave nearly every function in a test logging nothing, with no failure to
explain why.
Declaring a log group in a template
Section titled “Declaring a log group in a template”AWS::Logs::LogGroup is deployed by simulated CloudFormation. A test can then assert on the
retention a stack gave a group.
OrdersLogs: Type: AWS::Logs::LogGroup Properties: LogGroupName: /aws/lambda/orders RetentionInDays: 14Ref resolves to the log group name, and Fn::GetAtt Arn to the ARN with its trailing :*. That is
the form a policy has to name. A template granting a function permission on its own log group gets a
resource that reaches the streams inside it.
LogGroupName and RetentionInDays are the two properties acted on. A RetentionInDays outside the
set AWS accepts fails the deploy, where otherwise it would only be found on a real one. Everything
else is recorded as an ignored property. A reader can see what a deployed group leaves out, and the
stack still deploys.
Two divergences to know about:
- A group that already exists is taken over. Real CloudFormation fails a deploy that declares a
log group already in the account. That is a genuine misconfiguration there and pure noise here,
where a Lambda function that logged during test setup has already created
/aws/lambda/orders. - An update replaces the group. Simulated CloudFormation has no in-place update at all. Any
resource whose template entry changed is deleted and created again. The retention ends up correct,
but the events the group held are gone, where a real update to
RetentionInDayskeeps them.
Subscription filters
Section titled “Subscription filters”A subscription filter delivers the events matching its pattern to a Lambda function. Code written to forward log lines to an error tracker or a metrics sink can be tested against the handler it forwards from.
/** * Delivering matched log events to a simulated Lambda function. */
import { gunzipSync } from "node:zlib";
import { CreateLogGroupCommand, CreateLogStreamCommand, PutLogEventsCommand, PutSubscriptionFilterCommand,} from "@aws-sdk/client-cloudwatch-logs";import { AddPermissionCommand, CreateFunctionCommand,} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
const simAws = new SimAws();const logGroupName = "/aws/lambda/orders";const logStreamName = "2026/08/16/[$LATEST]0f7c1a";const received: string[] = [];
await simAws.lambda().createFunction( new CreateFunctionCommand({ FunctionName: "error-tracker", Role: `arn:aws:iam::${simAws.defaultAccountId}:role/TrackerRole`, Code: { ZipFile: makeLambdaZipFileInput( (event: { awslogs: { data: string } }) => { const decoded = JSON.parse( gunzipSync(Buffer.from(event.awslogs.data, "base64")).toString(), ) as { logEvents: { message: string }[] };
received.push(...decoded.logEvents.map((line) => line.message));
return "recorded"; }, ), }, }),);
// CloudWatch Logs invokes as a regional service principal, so this is the// grant a subscription filter needs on the function's side.await simAws.lambda().addPermission( new AddPermissionCommand({ FunctionName: "error-tracker", StatementId: "logs", Action: "lambda:InvokeFunction", Principal: `logs.${simAws.defaultRegionName}.amazonaws.com`, }),);await simAws.backgroundTasksComplete();
await simAws.logs().createLogGroup(new CreateLogGroupCommand({ logGroupName }));await simAws .logs() .createLogStream(new CreateLogStreamCommand({ logGroupName, logStreamName }));
await simAws.logs().putSubscriptionFilter( new PutSubscriptionFilterCommand({ logGroupName, filterName: "errors-to-tracker", filterPattern: "ERROR", destinationArn: `arn:aws:lambda:${simAws.defaultRegionName}:${simAws.defaultAccountId}:function:error-tracker`, }),);
await simAws.logs().putLogEvents( new PutLogEventsCommand({ logGroupName, logStreamName, logEvents: [ { timestamp: Date.parse("2026-08-16T09:00:00Z"), message: "INFO starting", }, { timestamp: Date.parse("2026-08-16T09:00:01Z"), message: "ERROR order has no items", }, ], }),);
// Delivery happens after the write is answered, as it does in an account.await simAws.backgroundTasksComplete();
console.log(received);The payload is the real one. An awslogs.data field holds the base64 of a gzipped JSON document
with messageType, owner, logGroup, logStream, subscriptionFilters and logEvents. A
handler written against a real subscription decodes it unchanged.
The behaviour in detail:
- Delivery is asynchronous.
PutLogEventsis answered before anything is delivered. A test waits withawait simAws.backgroundTasksComplete(). A destination that throws leaves the write that triggered it alone. - A failed delivery is kept. Real CloudWatch Logs tells nobody when a delivery fails, and it
becomes a metric nobody is watching.
simAws.logs().subscriptionFailuresholds them. A test can find out that the subscription it set up never reached anything. - The destination is checked when the filter is put. A function that has yet to grant
logs.<region>.amazonaws.compermission to invoke it failsPutSubscriptionFilter, as it does in an account. The alternative would be a filter that silently drops every event. The resource policy is consulted again on every delivery, and a permission removed later stops delivery too. - What a Lambda function logged is delivered as well. A subscription on
/aws/lambda/orderspicks up what that function wrote. A forwarder can be tested against a real handler’s output. - Lambda is the only destination. Kinesis, Firehose and cross-account destinations are refused outright.
- A destination can name a version or an alias. See Subscribing a Lambda alias.
- Two filters per log group, the current AWS account default.
Subscribing a Lambda alias
Section titled “Subscribing a Lambda alias”A destinationArn can carry a version number or an alias name on the end, and matched events go to
the version that qualifier names. The grant is made on the same qualifier:
/** * Delivering matched log events to a simulated Lambda alias. */
import { CreateLogGroupCommand, CreateLogStreamCommand, PutLogEventsCommand, PutSubscriptionFilterCommand,} from "@aws-sdk/client-cloudwatch-logs";import { AddPermissionCommand, CreateAliasCommand, CreateFunctionCommand, PublishVersionCommand,} from "@aws-sdk/client-lambda";
import { SimAws } from "@kensio/yulin";import { makeLambdaZipFileInput } from "@kensio/yulin/lambda";
const simAws = new SimAws();const lambda = simAws.lambda();const logGroupName = "/aws/lambda/orders";const logStreamName = "2026/08/19/[$LATEST]0f7c1a";const trackerArn = `arn:aws:lambda:${simAws.defaultRegionName}:${simAws.defaultAccountId}:function:error-tracker`;
await lambda.createFunction( new CreateFunctionCommand({ FunctionName: "error-tracker", Role: `arn:aws:iam::${simAws.defaultAccountId}:role/TrackerRole`, Code: { ZipFile: makeLambdaZipFileInput((_event, context) => { console.log(context.functionVersion); // "1", the version behind `live`
return "recorded"; }), }, }),);await simAws.backgroundTasksComplete();
const published = await lambda.publishVersion( new PublishVersionCommand({ FunctionName: "error-tracker" }),);
await lambda.createAlias( new CreateAliasCommand({ FunctionName: "error-tracker", Name: "live", FunctionVersion: published.Version, }),);
await lambda.addPermission( new AddPermissionCommand({ FunctionName: "error-tracker", Qualifier: "live", StatementId: "logs", Action: "lambda:InvokeFunction", Principal: `logs.${simAws.defaultRegionName}.amazonaws.com`, }),);
await simAws.logs().createLogGroup(new CreateLogGroupCommand({ logGroupName }));await simAws .logs() .createLogStream(new CreateLogStreamCommand({ logGroupName, logStreamName }));
await simAws.logs().putSubscriptionFilter( new PutSubscriptionFilterCommand({ logGroupName, filterName: "errors-to-tracker", filterPattern: "ERROR", destinationArn: `${trackerArn}:live`, }),);
await simAws.logs().putLogEvents( new PutLogEventsCommand({ logGroupName, logStreamName, logEvents: [{ timestamp: 1000, message: "ERROR order has no items" }], }),);
await simAws.backgroundTasksComplete();A qualifier naming no version and no alias is refused where the filter is put, the way a missing
function is. UpdateAlias moves what the filter reaches, and the filter stays as it is.
Permissions
Section titled “Permissions”Every operation goes through simulated IAM. An operation on a named log group authorizes against
that group’s ARN with the trailing :*. That is the form CloudWatch Logs policies are written in.
Granting logs:PutLogEvents on a group grants it on the streams inside, and the wildcard is what
covers them. A policy naming log-group:/aws/lambda/orders without it reaches no stream here,
exactly as on real AWS.
DescribeLogGroups names no particular group. It authorizes against every log group in the account
and region, and a policy scoped to one group cannot describe them all.
/** * A simulated IAM policy allowing a Role to write one function's logs. */
import { CreateLogGroupCommand, CreateLogStreamCommand,} from "@aws-sdk/client-cloudwatch-logs";import { CreateRoleCommand, PutRolePolicyCommand } from "@aws-sdk/client-iam";
import { SimAws } from "@kensio/yulin";
const simAws = new SimAws();const accountId = simAws.defaultAccountId;const regionName = simAws.defaultRegionName;const logGroupName = "/aws/lambda/orders";
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: "WriteOwnLogs", PolicyDocument: JSON.stringify({ Version: "2012-10-17", Statement: { Effect: "Allow", Action: [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", ], // The trailing wildcard covers the streams inside the group. Resource: `arn:aws:logs:${regionName}:${accountId}:log-group:${logGroupName}:*`, }, }), }),);
const asRole = { caller: { kind: "arn", arn: role.Role.Arn } } as const;
await simAws .logs() .createLogGroup(new CreateLogGroupCommand({ logGroupName }), asRole);await simAws.logs().createLogStream( new CreateLogStreamCommand({ logGroupName, logStreamName: "2026/08/16/[$LATEST]0f7c1a", }), asRole,);
console.log(simAws.logs().findLogGroup(logGroupName)?.logGroupArn);Through an intercepted SDK client
Section titled “Through an intercepted SDK client”Application code that constructs its own CloudWatchLogsClient reaches the simulator through SDK
interception, with the code under test unchanged.
/** * Reaching simulated CloudWatch Logs through an intercepted SDK client. */
import { CloudWatchLogsClient, CreateLogGroupCommand, DescribeLogGroupsCommand,} from "@aws-sdk/client-cloudwatch-logs";
import { SimSdk } from "@kensio/yulin/sdk";
using simSdk = new SimSdk();simSdk.intercept(CloudWatchLogsClient);
const client = new CloudWatchLogsClient({ region: "eu-west-2" });
await client.send( new CreateLogGroupCommand({ logGroupName: "/aws/lambda/orders" }),);
const described = await client.send(new DescribeLogGroupsCommand({}));
// The ARN names the Region the client was configured for.console.log(described.logGroups?.[0]?.logGroupArn);Limitations
Section titled “Limitations”- Events never expire. Retention is stored and reported, never acted on.
- Metric filters. Absent, so
metricFilterCountis always zero. - Subscription filter destinations other than Lambda, and
Distribution.Distributionis accepted and reported, and with no shards to spread across it has no effect. AWS::Logs::SubscriptionFilterandAWS::Logs::MetricFilter.AWS::Logs::LogGroupis the only CloudFormation resource type here, and the others are recorded as gaps.- Logs Insights, export tasks, tags, encryption and data protection policies. Absent. Tags and
kmsKeyIdonCreateLogGroupare refused outright. A property cannot look set here and behave differently in an account. - Per-stream
storedBytes. Always zero, matching real CloudWatch Logs, which stopped reporting the figure per stream in 2019.DescribeLogGroupsreports the bytes a group holds. - Log capture from a handler function reference. Recorded through the process console and the
process standard streams, both of which a test runner is free to replace.
console.traceandconsole.dirreach the log group only where the host console passes them on toprocess.stdout. See the Lambda docs for the detail.
Software Engineering by Kensio Software
This page as plain text: llms.txt
Documenting Yulin v1.20.2
