Application events
EventBridge
Day Eight #
The asset workflow now makes processing visible, but only code that knows about the state machine or DynamoDB table can observe the result. Adding an audit consumer directly to each terminal Lambda would make those functions responsible for every new destination.
Today we introduce EventBridge. The workflow will publish one versioned application event when an asset becomes ready, rejected, or failed. A rule will route those events to an operator-facing CloudWatch Logs group without teaching the workflow anything about that consumer.
The browser flow does not change. EventBridge carries facts about work that already reached a terminal application state; it does not replace SQS admission, Step Functions orchestration, DynamoDB state, or the status API.
Goal #
For every preview environment:
- create a custom EventBridge bus for application events
- publish one
Asset State Changedevent from every terminal workflow path - define a small, versioned event contract without identity or internal error details
- use EventBridge's Step Functions integration instead of another publisher Lambda
- route matching events to a dedicated CloudWatch Logs audit stream
- retry failed target delivery and retain exhausted deliveries in a separate SQS dead-letter queue
- grant the state machine permission to publish only to its preview bus
- expose the bus, audit log, and delivery DLQ through stack outputs
The new boundary #
S3 ObjectCreated
|
v
SQS admission queue
|
v
Step Functions workflow
|
+-- validate -> transform -> store ready
| |
+-- validate -> store rejected |
| | |
+-- exhausted error -> store failed
| |
v v
PutEvents integration
|
v
custom EventBridge bus
|
matching event rule
|
v
lifecycle audit log
|
exhausted delivery
v
delivery DLQ
The three delivery mechanisms have distinct jobs:
| Component | Owns |
|---|---|
| SQS admission queue | Buffering an S3 notification until a workflow starts |
| Step Functions | Processing order, branches, task retries, and terminal state |
| EventBridge | Matching a completed lifecycle fact to interested consumers |
In contrast with SQS, EventBridge producers and consumers are more decoupled. An EventBridge bus rule evaluates an event and sends it to each configured target. SQS gives messages to competing workers, and consumers ask for the next event later. Use SQS when work must wait durably for a consumer; use EventBridge when publishers should announce facts without knowing who consumes them.
Events #
The workflow publishes in the past tense. Asset State Changed says something happened. The consumer is left with the fact that something has changed already.
That distinction keeps ownership clear:
- Step Functions decides and records the processing outcome.
- DynamoDB remains the source of current application state.
- EventBridge distributes the outcome after the state change succeeds.
- A consumer may build an audit trail, send a notification, or start another reaction, but it does not redefine whether the asset is ready.
For a small application with one known follow-up action, calling that action directly can be simpler. EventBridge earns its place once multiple independently deployed consumers need the same facts, routing rules change independently, or integrations cross application or account boundaries.
A custom event bus #
Use a dedicated bus instead of the account's default bus:
const lifecycleBus = new events.EventBus(this, 'AssetLifecycleBus', {
description: 'Application events emitted by the asset processing workflow',
});
The default bus already receives AWS service events. A custom bus gives the application an explicit publishing boundary, keeps its rules separate, and gives the workflow role one concrete ARN for events:PutEvents.
Only IAM principals in this preview account that receive permission may publish. A later production design can add a narrowly scoped bus policy when another account genuinely needs access.
The event contract #
All terminal paths use one envelope identity:
{
"source": "com.example.assets",
"detail-type": "Asset State Changed",
"detail": {
"schemaVersion": "1",
"assetId": "307d0b55-05d1-4c0f-a0fb-4c3e1083e3df",
"status": "ready",
"contentType": "image/png",
"outputKey": "processed/assets/307d0b55-05d1-4c0f-a0fb-4c3e1083e3df.png"
}
}
EventBridge adds fields such as id, account, region, and time. The application owns source, detail-type, and detail.
The ready detail includes the deterministic output key. A rejected event includes contentType and the safe rejection reason. A failed event includes only the asset ID, status, schema version, and the same safe operator message stored in DynamoDB.
Do not include the Cognito subject, email address, presigned URL, or raw Step Functions error. Rules and targets often broaden over time, logs have their own readers and retention, and a presigned URL is a temporary credential. Consumers that are authorized to read more can use assetId to request it from the system of record.
schemaVersion is a string on purpose. Consumers can explicitly accept version 1, and a later breaking shape can become version 2 without guessing from optional fields. Additive fields still require tolerant consumers: ignore fields you do not use, but validate the fields you do.
Direct publishing #
Step Functions has an optimized PutEvents integration, so no publisher Lambda is needed. Define a small task factory:
const publishLifecycleEvent = (
id: string,
detail: Record<string, unknown>,
) => new tasks.EventBridgePutEvents(this, id, {
entries: [{
eventBus: lifecycleBus,
source: 'com.example.assets',
detailType: 'Asset State Changed',
detail: sfn.TaskInput.fromObject(detail),
}],
resultPath: sfn.JsonPath.DISCARD,
});
The ready publication selects only the fields its contract needs:
const publishReady = publishLifecycleEvent('Publish asset ready', {
schemaVersion: '1',
assetId: sfn.JsonPath.stringAt('$.assetId'),
status: 'ready',
contentType: sfn.JsonPath.stringAt('$.contentType'),
outputKey: sfn.JsonPath.stringAt('$.outputKey'),
});
Define equivalent tasks for rejected and failed. The failure event uses a fixed safe reason rather than selecting $.failure, which contains the internal task exception.
Connect publication after the DynamoDB update on each branch:
const definition = invokeValidate.next(
new sfn.Choice(this, 'Bytes match declared image type?')
.when(
sfn.Condition.booleanEquals('$.valid', true),
invokeTransform
.next(invokeComplete)
.next(publishReady)
.next(ready),
)
.otherwise(
invokeReject
.next(publishRejected)
.next(rejected),
),
);
invokeRecordFailure.next(publishFailed).next(failed);
Publishing after the update gives the event an honest meaning: when a consumer receives ready, DynamoDB already says ready. It also exposes an unavoidable distributed-systems boundary. The database update and PutEvents call are not one transaction. If publication ultimately fails, the asset remains in its correct terminal state while the workflow execution identifies the unsuccessful publication task.
For a system that cannot tolerate this dual-write window, use an outbox pattern: commit state and an outbox record atomically, then relay the outbox to EventBridge. That is a stronger guarantee with more storage, relay, and deduplication machinery than this preview needs.
Publication retries and permissions #
PutEvents can return HTTP success while rejecting an individual entry. The optimized Step Functions integration checks FailedEntryCount and fails the task with EventBridge.FailedEntry, so the workflow retry policy applies:
for (const task of [publishReady, publishRejected, publishFailed]) {
task.addRetry({
errors: ['States.TaskFailed'],
interval: cdk.Duration.seconds(2),
maxAttempts: 3,
backoffRate: 2,
});
}
CDK derives an events:PutEvents policy for the state machine role and scopes its resource to lifecycleBus.eventBusArn. The workflow Lambdas receive no EventBridge permission because they do not publish.
Retries mean consumers must be idempotent. EventBridge delivery is at-least-once, and a producer retry can also create another event with a different envelope ID. For this application, consumers can use assetId, status, and schemaVersion as their logical deduplication identity.
Route the audit stream #
Create a rule on the custom bus. Match both stable envelope fields rather than accepting every event placed on the bus:
const lifecycleAuditRule = new events.Rule(this, 'AssetLifecycleAuditRule', {
eventBus: lifecycleBus,
description: 'Retain asset lifecycle events for operator inspection',
eventPattern: {
source: ['com.example.assets'],
detailType: ['Asset State Changed'],
},
});
The target is a dedicated log group with finite retention:
const lifecycleLogGroup = new logs.LogGroup(this, 'AssetLifecycleEvents', {
retention: isPreview
? logs.RetentionDays.ONE_WEEK
: logs.RetentionDays.ONE_MONTH,
removalPolicy: cleanupPolicy,
});
CloudWatch Logs is the first consumer, not part of the producer. Removing this rule or adding another rule requires no workflow change.
Create a second DLQ for this boundary:
const lifecycleDeliveryDeadLetterQueue = new sqs.Queue(
this,
'LifecycleDeliveryDeadLetterQueue',
{
encryption: sqs.QueueEncryption.SQS_MANAGED,
retentionPeriod: cdk.Duration.days(14),
},
);
lifecycleDeliveryDeadLetterQueue.addToResourcePolicy(new iam.PolicyStatement({
principals: [new iam.ServicePrincipal('events.amazonaws.com')],
actions: ['sqs:SendMessage'],
resources: [lifecycleDeliveryDeadLetterQueue.queueArn],
conditions: {
ArnEquals: { 'aws:SourceArn': lifecycleAuditRule.ruleArn },
},
}));
lifecycleAuditRule.addTarget(new targets.CloudWatchLogGroup(
lifecycleLogGroup,
{
deadLetterQueue: lifecycleDeliveryDeadLetterQueue,
maxEventAge: cdk.Duration.hours(24),
retryAttempts: 185,
installLatestAwsSdk: false,
},
));
The existing AssetsDeadLetterQueue holds S3 notifications that could not start a workflow. LifecycleDeliveryDeadLetterQueue holds accepted EventBridge events that could not reach the audit target. Combining them would erase the recovery boundary: one message needs workflow admission, while the other needs target delivery.
The CloudWatch Logs target creates the log resource policy needed by events.amazonaws.com. The explicit SQS resource policy grants the service permission to send exhausted deliveries to this queue, scoped to this rule's ARN. The established Logs APIs are available in the provider's bundled SDK, so installLatestAwsSdk: false avoids a deployment-time package installation. Application roles do not need permission to write the audit log or its DLQ.
One target per rule keeps ownership and retry policy easy to change. When the next consumer arrives, give it another rule even if its event pattern initially matches this one.
Infrastructure tests #
Extend the CDK assertions to verify the contract in the synthesized template:
preview.resourceCountIs('AWS::Events::EventBus', 1);
preview.hasResourceProperties('AWS::Events::Rule', {
EventPattern: {
source: ['com.example.assets'],
'detail-type': ['Asset State Changed'],
},
State: 'ENABLED',
Targets: [Match.objectLike({
DeadLetterConfig: Match.objectLike({ Arn: Match.anyValue() }),
RetryPolicy: {
MaximumEventAgeInSeconds: 86400,
MaximumRetryAttempts: 185,
},
})],
});
preview.hasResourceProperties('AWS::SQS::QueuePolicy', {
PolicyDocument: {
Statement: Match.arrayWith([Match.objectLike({
Action: 'sqs:SendMessage',
Effect: 'Allow',
Principal: { Service: 'events.amazonaws.com' },
Resource: Match.anyValue(),
Condition: {
ArnEquals: { 'aws:SourceArn': Match.anyValue() },
},
})]),
},
});
The state-machine assertion also checks that the synthesized definition contains three events:putEvents integrations and all three terminal statuses. This catches a branch that updates DynamoDB but forgets its event.
Run the complete suite:
npm test
Verification #
Deploy a preview, then resolve the new outputs:
STACK=Stack-PR123
REGION=us-east-1
EVENT_BUS=$(aws cloudformation describe-stacks \
--stack-name "$STACK" --region "$REGION" \
--query "Stacks[0].Outputs[?OutputKey=='AssetLifecycleEventBusName'].OutputValue | [0]" \
--output text)
LOG_GROUP=$(aws cloudformation describe-stacks \
--stack-name "$STACK" --region "$REGION" \
--query "Stacks[0].Outputs[?OutputKey=='AssetLifecycleLogGroupName'].OutputValue | [0]" \
--output text)
DELIVERY_DLQ=$(aws cloudformation describe-stacks \
--stack-name "$STACK" --region "$REGION" \
--query "Stacks[0].Outputs[?OutputKey=='AssetLifecycleDeliveryDeadLetterQueueName'].OutputValue | [0]" \
--output text)
Confirm that the rule belongs to the custom bus:
aws events list-rules \
--event-bus-name "$EVENT_BUS" \
--region "$REGION" \
--query 'Rules[].{name:Name,state:State,eventPattern:EventPattern}'
Sign in to the preview and upload a valid PNG or JPEG. After the workflow succeeds, inspect the audit stream:
aws logs tail "$LOG_GROUP" \
--since 10m \
--format short \
--region "$REGION"
Expect one Asset State Changed envelope whose detail has the uploaded asset ID and status: ready. Upload a renamed non-image and expect a second envelope with status: rejected. The Step Functions graph should show Publish asset ready or Publish asset rejected between the DynamoDB task and the terminal Succeed state.
Test the routing boundary independently of the workflow:
aws events put-events \
--region "$REGION" \
--entries "[{\"Source\":\"com.example.assets\",\"DetailType\":\"Asset State Changed\",\"Detail\":\"{\\\"schemaVersion\\\":\\\"1\\\",\\\"assetId\\\":\\\"diagnostic\\\",\\\"status\\\":\\\"ready\\\"}\",\"EventBusName\":\"$EVENT_BUS\"}]"
Expect FailedEntryCount to be zero, then tail the log again. A successful PutEvents response proves ingestion, not target delivery, so verify both commands.
Finally confirm that the target DLQ is empty:
DLQ_URL=$(aws sqs get-queue-url \
--queue-name "$DELIVERY_DLQ" \
--region "$REGION" \
--query QueueUrl \
--output text)
aws sqs get-queue-attributes \
--queue-url "$DLQ_URL" \
--attribute-names ApproximateNumberOfMessages \
--region "$REGION"
Failure modes and quotas #
- If the workflow role cannot call
PutEvents, the publication task retries and the execution fails visibly after its attempts are exhausted. The already committed DynamoDB status remains authoritative. - If EventBridge accepts an event but the log target is unavailable, the rule retries for up to 24 hours and 185 attempts, then sends the complete event to the delivery DLQ.
- If EventBridge cannot send an exhausted delivery to the DLQ, the
InvocationsFailedToBeSentToDlqCloudWatch metric exposes a second-order failure. The CloudWatch chapter will alarm on signals like this. - EventBridge may deliver an event more than once. Consumers must make side effects idempotent instead of relying on the envelope
idalone. - Rules do not retain unmatched events. A new rule sees new events only; use an EventBridge archive when replay is a requirement.
PutEventsaccepts at most ten entries per request, and an event may be at most 256 KB. This workflow sends one small metadata event and never embeds asset bytes.- Event pattern size, rules per bus, targets per rule, and ingestion or invocation throughput have quotas. Defaults and throughput vary by Region; check Service Quotas before designing a high-volume bus.
Cost model #
Custom event ingestion is the main EventBridge charge. In US East (N. Virginia), custom events are currently $1.00 per million, and each 64 KB chunk is billed as one event. Delivery to a supported target in the same account is not an additional EventBridge delivery charge.
This workflow emits one small custom event per completed asset. CloudWatch Logs ingestion and storage are billed separately, and SQS charges apply if the target DLQ receives or serves messages. Step Functions also bills the additional PutEvents task transitions, including retries.
An audit rule that stores every event can cost more than EventBridge itself at volume. Finite log retention, small event details, and selective patterns keep that cost intentional.
Cleanup #
The normal pull-request teardown removes the custom bus, rule, audit log group, delivery DLQ, and the rest of the preview stack:
npx cdk destroy \
-c pr=123 \
-c acct="$CDK_DEFAULT_ACCOUNT" \
-c reg="$CDK_DEFAULT_REGION" \
--app "npx ts-node bin/preview.ts" \
-f
Any audit events and undelivered DLQ messages disappear with a preview. The production cleanup policy retains the log group, so deleting a production stack does not silently erase its audit trail. A retained log group requires explicit final cleanup and continues to incur storage cost.
Operating model #
- DynamoDB is the source of current asset state; EventBridge distributes changes to that state.
- The custom bus is an application boundary. It is not a replacement for the SQS admission buffer or Step Functions workflow.
- The workflow knows the event contract and bus, but not the audit target. Rules own consumer selection.
- Events contain stable identifiers and safe metadata, never credentials, user identity, object bytes, or internal exception details.
- Publication and target delivery have separate retries. The workflow history diagnoses the former; EventBridge metrics and the delivery DLQ diagnose the latter.
- Consumers assume at-least-once delivery and deduplicate logical events before making non-repeatable side effects.
Well-Architected Framework #
- Security: only the state machine role can publish to the preview bus, targets receive resource-scoped service permissions, and the event contract excludes credentials and user identity.
- Operational Excellence: a searchable lifecycle stream shows application outcomes independently of workflow execution history, while distinct DLQs identify the failed boundary.
- Reliability: bounded producer retries handle partial
PutEventsfailure; target retries and a 14-day DLQ retain exhausted deliveries; consumers are designed for duplicates. - Performance Efficiency: small metadata events move through EventBridge while image bytes remain in S3.
- Cost Optimization: one event per terminal asset, selective matching, and finite preview log retention bound ingestion and storage costs.
- Sustainability: direct Step Functions integration avoids an otherwise idle publisher function and moves only the data each consumer needs.