← Workshops Β· Part 8 Β· Event-Driven

Event-Driven Development with Amazon EventBridge

Let services react to each other without calling each other. Naming, timing, reliability, and turning your event stream into analytics β€” on Amazon EventBridge.

πŸ“ Test yourself ↓
35 min readEvent-DrivenRead or present

What you'll learn

How to build event-driven services on Amazon EventBridge β€” the pattern JunctionNet and CargoNaut use so services can react to each other without ever calling each other directly. A producer publishes a fact; any number of consumers subscribe by pattern and run in their own Lambda. The producer never knows they exist.

🧭

The one-line idea: instead of "service A calls service B", service A announces "this happened" and B (and C, and a dashboard, and an archive) decide for themselves whether they care.

πŸ”—

This builds on Part 6. There we published a single NoteCreated event from one SAM stack. Here we make events a first-class design tool across services β€” naming, timing, reliability, and turning the event stream into analytics.

πŸŒ“

Python & Node.js. The publisher and consumer code blocks below have a Python / Node.js toggle β€” pick your runtime. The SAM wiring is identical; only the function's Runtime (python3.13 or nodejs20.x) and Handler differ.

The mental model

Four moving parts, and only four. Learn these and the rest is detail:

producer  ──put_events──▢  event bus  ──rule (pattern match)──▢  target (consumer)
                              β”‚                                    β”œβ”€β–Ά Lambda  (react)
                              β”‚                                    β”œβ”€β–Ά SQS     (buffer + retry)
                              β”‚                                    β”œβ”€β–Ά Firehose β–Ά OpenSearch (analytics)
                              β”‚                                    └─▢ another bus / API
  β–² one bus per environment (e.g. Platform-Apps-events-dev)
  β–² a producer knows the bus β€” never the consumers
πŸ“£

Producer

Publishes a domain event after something happens. Fire-and-forget.

🚌

Event bus

A named router. One per stage; every event lands here first.

🎯

Rule

A pattern on source / detail-type that forwards matching events to targets.

πŸ‘‚

Consumer

The target β€” a Lambda, queue, or stream that does the follow-up work.

Anatomy

What an event actually looks like

You control four fields when you publish; EventBridge stamps the rest (id, time, account, region). Rules match on the top-level envelope β€” mainly source and detail-type:

{
  // --- you set these ---
  "EventBusName": "Platform-Apps-events-dev",
  "Source":       "cargonaut.shipment.router",   // who emitted it
  "DetailType":   "ShipmentDelivered",           // what happened (a fact)
  "Detail": {                                    // the payload (your JSON)
    "shipment_id": "shp_123",
    "org_id": "org_abc",
    "occurred_at": "2026-07-12T10:04:00Z"
  },
  // --- EventBridge adds: id, time, region, account, resources ---
}
Best practice Β· 1

Naming the source

source answers "who emitted this?" Use a stable, dotted, lowercase namespace β€” never a display name or something that changes. The platform convention:

# {platform}.{service}.{module}   β€” dotted, lowercase, stable
cargonaut.shipment.router
cargonaut.document.extractor
blueprint.events_management.publisher
audit.import
⚠️

Don't encode data in the source. cargonaut.shipment.org_abc is an anti-pattern β€” org/tenant belongs in Detail, not the routing key. The source is a fixed set of values, not a high-cardinality field.

Best practice Β· 2

Naming the detail-type

detail-type answers "what happened?" The rule that makes an event-driven system sane: events are facts, in the past tense β€” {Entity}{PastTenseVerb}, PascalCase.

Good (a fact)Bad (a command / vague)Why
ShipmentDeliveredDeliverShipmentAn event reports the past; a command tells someone to act. Publishing commands re-couples you.
InvoiceExtractedinvoice_updatePascalCase + specific verb. update forces every consumer to re-inspect the payload to learn what changed.
DocumentClassifiedDocEventOne type = one meaning. A catch-all type defeats pattern-matching.
🧾

Past tense is the discipline. If you can't name it in the past tense, it's probably a command, not an event β€” and the caller should own that work, not broadcast it. "Deliver this" is a request to one owner; "It was delivered" is a fact anyone may react to.

Versioning

Payloads evolve. Add fields freely (consumers ignore unknowns); for a breaking change, publish a new type (ShipmentDeliveredV2) and retire the old one once no rule matches it β€” never silently repurpose an existing type.

Best practice Β· 3

When & where to publish

The single most common bug in event-driven code is publishing at the wrong moment. The rule: publish a fact only after the state change is durably committed, from the service layer, off the request's critical path.

# application/services.py β€” publish AFTER the write succeeds
def deliver_shipment(self, shipment_id: str) -> Shipment:
    shipment = self.repository.mark_delivered(shipment_id)   # 1. commit the fact
    self.event_bus.publish(                                  # 2. then announce it
        "ShipmentDelivered",
        {"shipment_id": shipment.id, "org_id": shipment.org_id},
    )
    return shipment
// application/services.js β€” publish AFTER the write succeeds
import { EventBridgeClient, PutEventsCommand } from "@aws-sdk/client-eventbridge";
const eb = new EventBridgeClient({});

export async function deliverShipment(shipmentId) {
  const shipment = await repository.markDelivered(shipmentId);   // 1. commit the fact
  await eb.send(new PutEventsCommand({                           // 2. then announce it
    Entries: [{
      EventBusName: process.env.EVENT_BUS,
      Source: "cargonaut.shipment.router",
      DetailType: "ShipmentDelivered",
      Detail: JSON.stringify({ shipmentId: shipment.id, orgId: shipment.orgId }),
    }],
  }));
  return shipment;
}
⚠️

The dual-write caveat. "Write DB, then put_events" is two operations β€” a crash between them drops the event. For most flows that's acceptable (log & alert). When you truly can't lose an event, use the transactional outbox: write the event to a table in the same DB transaction, then a separate process relays it to EventBridge.

Best practice Β· 4

Subscribing: rules & patterns

A consumer declares an event pattern β€” EventBridge only invokes it for matching events. In SAM this is an EventBridgeRule event source; SAM creates the rule and the invoke permission:

# template.yaml β€” a consumer that only wakes for two shipment facts
OnShipmentEventFn:
  Type: AWS::Serverless::Function
  Properties:
    Handler: shipment.handlers.event_bus.on_shipment.handler
    Events:
      ShipmentFacts:
        Type: EventBridgeRule
        Properties:
          EventBusName: !Ref PlatformBus
          Pattern:
            source: [ cargonaut.shipment.router ]
            detail-type: [ ShipmentDelivered, ShipmentCancelled ]
Best practice Β· 5

Make it reliable

EventBridge delivers at least once β€” occasionally twice, occasionally out of order. Design consumers for that:

πŸ”

Idempotent

Key side-effects on the event id (or a business key) so a re-delivery is a no-op.

πŸͺ€

DLQ

Attach a dead-letter queue to the rule/target so a poison event is captured, not lost.

♻️

Loop-safe

If a consumer publishes, carry a hop-count in detail so A→B→A can't run forever.

πŸ“¦

Buffer with SQS

Rule β†’ SQS β†’ Lambda smooths spikes and gives you retries + batch control.

🧩

Order isn't guaranteed. Don't assume ShipmentCreated arrives before ShipmentDelivered. Make handlers tolerant (upsert, check current state) rather than assuming a sequence.

Setup

Wiring event β†’ SQS β†’ Lambda

A rule can target a Lambda directly (as above), but for anything bursty or rate-sensitive you put an SQS queue in between: the rule delivers to the queue, and the Lambda drains it in batches. The queue becomes a durable buffer β€” it absorbs spikes, gives you real retry control, and hands poison messages to a dead-letter queue.

event ──rule──▢ SQS queue ──batch──▢ Lambda
                   └──(after N failed receives)──▢ dead-letter queue

The SAM template β€” four pieces

# template.yaml
Resources:
  # 1. the buffer queue + a dead-letter queue for poison messages
  ShipmentDLQ:
    Type: AWS::SQS::Queue
    Properties: { MessageRetentionPeriod: 1209600 }   # 14 days
  ShipmentQueue:
    Type: AWS::SQS::Queue
    Properties:
      VisibilityTimeout: 180                # β‰₯ ~6Γ— the Lambda timeout
      RedrivePolicy:
        deadLetterTargetArn: !GetAtt ShipmentDLQ.Arn
        maxReceiveCount: 5                  # give up after 5 tries β†’ DLQ

  # 2. an EventBridge rule that delivers matching events TO the queue
  ShipmentRule:
    Type: AWS::Events::Rule
    Properties:
      EventBusName: !Ref PlatformBus
      EventPattern:
        source: [ cargonaut.shipment.router ]
        detail-type: [ ShipmentDelivered, ShipmentCancelled ]
      Targets:
        - Id: shipment-queue
          Arn: !GetAtt ShipmentQueue.Arn

  # 3. let EventBridge write to the queue (scoped to THIS rule)
  ShipmentQueuePolicy:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues: [ !Ref ShipmentQueue ]
      PolicyDocument:
        Statement:
          - Effect: Allow
            Principal: { Service: events.amazonaws.com }
            Action: sqs:SendMessage
            Resource: !GetAtt ShipmentQueue.Arn
            Condition:
              ArnEquals: { aws:SourceArn: !GetAtt ShipmentRule.Arn }

  # 4. the Lambda drains the queue in batches (SAM makes the event-source mapping)
  OnShipmentEventFn:
    Type: AWS::Serverless::Function
    Properties:
      Handler: shipment.handlers.sqs.on_shipment.handler
      Runtime: python3.13
      Events:
        FromQueue:
          Type: SQS
          Properties:
            Queue: !GetAtt ShipmentQueue.Arn
            BatchSize: 10
            FunctionResponseTypes: [ ReportBatchItemFailures ]   # partial-batch retries

The handler β€” one record at a time, retry only what failed

Each SQS message body is the full EventBridge envelope, so parse record.body to get back detail-type and detail. Use Powertools' batch processor so a single bad message is retried on its own instead of re-running the whole batch:

# src/shipment/handlers/sqs/on_shipment.py
import json
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.batch import (
    BatchProcessor, EventType, process_partial_response)
from aws_lambda_powertools.utilities.data_classes.sqs_event import SQSRecord

logger = Logger(service="ShipmentEvents")
processor = BatchProcessor(event_type=EventType.SQS)

def record_handler(record: SQSRecord):
    event = json.loads(record.body)              # the EventBridge envelope
    detail = event["detail"]
    if event["detail-type"] == "ShipmentDelivered":
        logger.info("delivered", extra={"id": detail["shipment_id"]})
        # …idempotent work; raise to retry ONLY this message

def handler(event, context):
    return process_partial_response(event, record_handler, processor, context)
// src/shipment/handlers/sqs/onShipment.js
export const handler = async (event) => {
  const batchItemFailures = [];
  for (const record of event.Records) {
    try {
      const evt = JSON.parse(record.body);          // the EventBridge envelope
      if (evt["detail-type"] === "ShipmentDelivered") {
        // …idempotent work; throw to retry ONLY this message
      }
    } catch (err) {
      batchItemFailures.push({ itemIdentifier: record.messageId });
    }
  }
  return { batchItemFailures };                       // ReportBatchItemFailures
};
πŸ”€

Direct vs buffered β€” pick per consumer. Rule β†’ Lambda is fine for low-volume, latency-sensitive reactions (EventBridge already retries for 24h and can DLQ). Reach for rule β†’ SQS β†’ Lambda when you need batching, concurrency limits, long/precise retries, or ordering.

The payoff

Extending development with events

This is why the ceremony pays off. Because producers don't know their consumers, new behaviour is a new subscriber β€” you add a feature without opening, testing, or redeploying the service that owns the data.

# Day 1: shipment-service publishes ShipmentDelivered.
# Later, with ZERO changes to shipment-service, add consumers:
ShipmentDelivered
   β”œβ”€β–Ά notification-service   # email the customer
   β”œβ”€β–Ά usage-service          # meter a billable event
   β”œβ”€β–Ά cockpit-service        # close the action card
   └─▢ analytics (OpenSearch) # dashboards + audit trail
Observability

Three things every event platform needs

Once services talk through a bus, the bus becomes the place you debug from β€” and an unobserved bus is worse than no bus at all, because failures stop being exceptions in a caller's stack trace and start being events that simply never arrived. Three pieces cover it, and they deploy together:

producers ──put_events──► EventBridge bus ──┬─► rule (Error/*) ─► SQS ─► digest Ξ» ─► SNS ─► you
                                            └─► rule (all)  ──────────► forwarder Ξ» ─► OpenSearch

          + CloudWatch alarms on the bus itself (failed invocations, throttles, DLQ depth)
          + a daily report for what the stream can't see (cost, quotas, capacity)
PieceAnswersWithout it
Error digest (rule β†’ SQS β†’ Lambda β†’ SNS)"Is something broken right now?"Either silence, or an alert storm nobody reads after the second incident.
OpenSearch forwarder (rule β†’ Lambda β†’ index)"What happened to shipment X, and when?"You debug distributed behaviour by grepping eleven log groups.
Bus alarms (CloudWatch)"Is the plumbing itself healthy?"Throttled rules and failed invocations are invisible β€” events vanish with no error anywhere in your code.
🧭

The bus is a shared resource, so its observability is too. None of this belongs in a service stack. It ships with the bus β€” one stack, one owner, one place to look β€” the same split as the platform-vs-service layering in Part 4.

The bus, with its plumbing attached

The bus stack creates more than an AWS::Events::EventBus. It also publishes the bus to SSM so no service ever hardcodes its name, and it creates the error path that the digest Lambda will drain:

PlatformEventBus:
  Type: AWS::Events::EventBus
  Properties:
    Name: CargoNaut-Apps-events-${self:provider.stage}

# the contract: services discover the bus here, never by string literal
EventBusNameParameter:
  Type: AWS::SSM::Parameter
  Properties:
    Name: /platform/event_bus_name
    Value: !Ref PlatformEventBus

# one rule selects the failures β€” everything else flows to the forwarder
ErrorAlertsRule:
  Type: AWS::Events::Rule
  Properties:
    EventBusName: !Ref PlatformEventBus
    EventPattern:
      detail-type: [ "Error", "ProcessingError", "IntegrationError" ]
    Targets:
      - Arn: !GetAtt ErrorAlertsQueue.Arn
        Id: ErrorAlertsQueue
πŸ’‘

Errors are events too. Notice that failure isn't a separate channel β€” a service publishes ProcessingError to the same bus it publishes ShipmentDelivered to. One transport, one naming convention, and the alerting is a rule rather than a second integration in every service.

Throttled error alerts: rule β†’ SQS β†’ digest β†’ SNS

The obvious wiring β€” rule straight to SNS β€” works beautifully until the first real incident, when a retry loop emits four hundred IntegrationError events in ninety seconds and your team gets four hundred emails. After that, people filter the alert address, and you have built an outage detector nobody reads.

Buffer in SQS and batch. Two settings do the throttling, and neither is code:

AlertDigestFunction:
  Type: AWS::Serverless::Function
  Properties:
    ReservedConcurrentExecutions: 1        # only ever one digest in flight
    Events:
      ErrorQueue:
        Type: SQS
        Properties:
          Queue: !GetAtt ErrorAlertsQueue.Arn
          BatchSize: 100
          MaximumBatchingWindowInSeconds: 60   # wait up to a minute, then send

Concurrency 1 plus a 60-second batching window gives a hard ceiling of one notification per minute, no matter how loud the failure is. The Lambda's only job is to make that one message worth reading β€” group by source, de-duplicate, and cap the detail:

def lambda_handler(event, context):
    errors_by_source = {}
    for record in event.get("Records", []):                  # up to 100 per batch
        body   = json.loads(record.get("body", "{}"))
        detail = body.get("detail", {})
        errors_by_source.setdefault(body.get("source", "unknown"), []).append({
            "type":  body.get("detail-type", "Error"),
            "org":   detail.get("organization", "unknown"),
            # an alert is a pointer, not a log
            "error": str(detail.get("error") or detail.get("message", ""))[:200],
        })
    # collapse "the same error 300 times" to one line, 5 distinct per source
    blocks, total = [], sum(len(v) for v in errors_by_source.values())
    for source, errs in errors_by_source.items():
        seen = {f"{e['type']}:{e['error'][:80]}": e for e in errs}
        blocks.append(f"--- {source} ({len(errs)}) ---")
        blocks += [f"  [{e['type']}] {e['org']}: {e['error']}" for e in list(seen.values())[:5]]
    sns.publish(TopicArn=TOPIC, Subject=f"[{STAGE}] {total} error(s)",
                Message="\n".join(blocks))
export const handler = async (event) => {
  const bySource = {};
  for (const record of event.Records ?? []) {          // up to 100 per batch
    const body = JSON.parse(record.body ?? "{}"), detail = body.detail ?? {};
    (bySource[body.source ?? "unknown"] ??= []).push({
      type: body["detail-type"] ?? "Error", org: detail.organization ?? "unknown",
      // an alert is a pointer, not a log
      error: String(detail.error ?? detail.message ?? "").slice(0, 200),
    });
  }
  // collapse "the same error 300 times" to one line, 5 distinct per source
  const blocks = [], total = Object.values(bySource).flat().length;
  for (const [source, errs] of Object.entries(bySource)) {
    const seen = new Map(errs.map((e) => [`${e.type}:${e.error.slice(0, 80)}`, e]));
    blocks.push(`--- ${source} (${errs.length}) ---`, ...[...seen.values()].slice(0, 5).map((e) => `  [${e.type}] ${e.org}: ${e.error}`));
  }
  await sns.send(new PublishCommand({ TopicArn: TOPIC, Subject: `[${STAGE}] ${total} error(s)`, Message: blocks.join("\n") }));
};
⚠️

Give the error queue a DLQ, and alarm on its depth. If the digest Lambda itself starts failing β€” a bad SNS ARN, a permissions change β€” messages retry three times and land in the dead-letter queue, and your alerting goes quiet at exactly the moment you need it. A DLQ-depth alarm is the alarm that watches the alarms.

Forward every event to OpenSearch β€” the stream becomes memory

The second rule is a catch-all: every event on the bus goes to a forwarder Lambda that indexes it. The same stream that drives your services becomes a searchable, durable record you can build dashboards on.

Events:
  AllPlatformEvents:
    Type: EventBridgeRule
    Properties:
      EventBusName: !Ref PlatformEventBus
      Pattern:
        account: [ !Ref 'AWS::AccountId' ]     # i.e. everything on this bus

The forwarder's real work is shaping. An EventBridge envelope is not a good document: the useful fields are buried in detail, and every producer nests them differently. Flatten to one schema at the boundary, once, so every dashboard can rely on it:

# domain.py β€” one document shape for every event, whatever produced it
class AppEventModel(BaseModel):
    source:       str                 # cargonaut.shipment
    event:        str                 # ShipmentDelivered  (the detail-type)
    payload:      Dict[str, Any]      # the whole detail, kept raw
    environment:  str
    application:  str
    organization: str
    username:     str
    timestamp:    str = Field(default_factory=lambda: datetime.utcnow().isoformat() + "Z")

    @field_serializer('payload')
    def serialize_payload(self, payload, _info) -> str:
        # stringify: producers put arbitrary shapes in here, and a mapping
        # explosion in OpenSearch is a painful way to find that out.
        return json.dumps(payload, ensure_ascii=False)
// domain.js β€” one document shape for every event, whatever produced it
export const toAppEvent = (event) => {
  const detail = event.detail ?? {};
  return {
    source:       event.source,                  // cargonaut.shipment
    event:        event["detail-type"],          // ShipmentDelivered
    // stringify: producers put arbitrary shapes in here, and a mapping
    // explosion in OpenSearch is a painful way to find that out.
    payload:      JSON.stringify(detail),
    environment:  detail.environment ?? "unknown",
    application:  detail.application ?? "unknown",
    organization: detail.organization ?? "default_org",
    username:     detail.username ?? "default_user",
    timestamp:    new Date().toISOString(),
  };
};

The handler itself stays trivial β€” shape it, save it, and skip your own noise:

# handler.py
EXCLUDED_DETAIL_TYPES = {"WarmupLambda"}      # don't index your own noise

@logger.inject_lambda_context(log_event=True)
@event_source(data_class=EventBridgeEvent)
def lambda_handler(event: EventBridgeEvent, context):
    if event.detail_type in EXCLUDED_DETAIL_TYPES:
        return True
    app_event = ServiceFactory.create_app_event_entity(event)
    ServiceFactory.create_app_events_forwarder_service().save(app_event)
    return True
// handler.js
const EXCLUDED_DETAIL_TYPES = new Set(["WarmupLambda"]);   // don't index your own noise

export const handler = async (event) => {
  if (EXCLUDED_DETAIL_TYPES.has(event["detail-type"])) return true;
  await repository.saveEvent(toAppEvent(event));
  return true;
};

Authentication to OpenSearch is SigV4 with the Lambda's own execution role β€” no password anywhere, the same keyless principle as everything else on the platform. One parameter decides which service you're signing for, and getting it wrong produces a 403 that looks like a network problem:

TargetSigning serviceIAM actionExtra step
Managed OpenSearch domaineses:ESHttp* on the domain ARNIf the domain has a restrictive access policy, add the forwarder role as a principal.
OpenSearch Serverless collectionaossaoss:APIAccessAll on the collection ARNIAM is not enough β€” you must also add the role to a data access policy on the collection.

Why the mirror earns its keep:

πŸ“Š

Two jobs, one stream. The bus is your nervous system (services reacting in real time) and, mirrored to OpenSearch, your memory (analytics, audit, BI). You modelled the events once for behaviour; the analytics arrive as a side effect.

🧨

A catch-all forwarder scales with your busiest event. Every event is an invocation and an indexing call β€” so a chatty producer shows up in your Lambda bill and your OpenSearch storage before it shows up anywhere else. Exclude warmup and health-check chatter by detail-type, set an index lifecycle policy from day one, and put an invocation-spike alarm on the forwarder so a runaway publisher pages you instead of quietly costing money.

Alarms on the bus itself

The failures above are your code's. These are the platform's, and they are the ones that produce the worst kind of incident β€” the one where nothing errors anywhere and events simply don't arrive:

AlarmMetricWhat it catches
EventBus-FailedInvocationsAWS/Events Β· FailedInvocationsA rule matched but the target rejected it β€” bad permissions, a deleted Lambda. The producer's put_events succeeded and it knows nothing.
EventBus-ThrottledRulesAWS/Events Β· ThrottledRulesYou're publishing faster than the rules can fan out. Silent, and it looks like "the consumer is slow".
ErrorAlerts-DLQ-DepthAWS/SQS Β· ApproximateNumberOfMessagesVisibleThe alerting pipeline itself is broken.
Forwarder invocation spike / errors / p99AWS/LambdaA runaway publisher, or OpenSearch rejecting writes.
πŸ’‘

Set TreatMissingData: notBreaching on all of them. These metrics are only emitted when something happens, so a healthy bus reports nothing at all. Left at the default, every alarm sits in INSUFFICIENT_DATA, and a dashboard of permanently-yellow alarms trains everyone to ignore it.

The daily report: what the stream can't tell you

Events tell you what your application did. They say nothing about whether the account underneath it is healthy β€” cost drift, a table filling up, Lambdas being throttled, a certificate quietly approaching expiry. That needs a scheduled collector, and it is the simplest EventBridge consumer in the platform: a cron rule and one Lambda.

Events:
  DailySchedule:
    Type: Schedule
    Properties:
      # 10:00 UTC = 4am Mexico City. Pick a time in YOUR team's morning.
      Schedule: cron(0 10 * * ? *)
      Enabled: !Ref ScheduleEnabled       # parameterised: off in ephemeral stages

The report sweeps the account and emails an HTML digest:

SectionWhat it checks
CloudWatch alarmsEverything currently in ALARM, with metric and timestamp.
Cost summaryToday vs yesterday vs the 7-day average, top 10 services, daily trend.
Lambda healthErrors, throttles, invocation spikes (>10K/24h), peak concurrency.
EventBridge healthRule invocation counts β€” the volume view the alarms don't give you.
RDS / VPC / API Gateway / S3CPU, free storage, connection headroom, inventory.
Action itemsAuto-generated: cost spikes >30%, CPU >90%, storage <5 GB, any throttling.

Two details make it useful rather than another ignored email. First, it emits the same data as a JSON snapshot to S3, which a portal renders as a live infra dashboard β€” one collector, two consumers. Second, the snapshot write happens before the email:

# Publish before sending: a snapshot failure must never block the email.
snapshot = build_snapshot(now, alarms, costs, lambda_health, ...)
snapshot_written = write_snapshot(snapshot)

html = html.replace("</body>", snapshot_section(snapshot) + "</body>")
send_email(report_date, html, snapshot)
⚠️

Cost Explorer only exists in us-east-1. boto3.client("ce") against any other region fails, so the collector pins that one client regardless of where it runs β€” while CloudWatch, RDS and SES clients stay in the workload region. It's a one-line gotcha that costs an afternoon the first time.

🧭

Key the snapshot by stage, not by account. If dev and production share an AWS account β€” common, and fine β€” an account-keyed object means the two stages silently overwrite each other and your dashboard shows whichever ran last.

Packaging it: from a platform folder to a one-click app

Everything above is generic. A bus, an error digest, a forwarder and some alarms are what any EventBridge platform needs β€” nothing in them is about freight. So the last step was to lift the whole thing out of the platform repo into marketplace/events-observability: one SAM template, parameterised, published to the AWS Serverless Application Repository.

Metadata:
  AWS::ServerlessRepo::Application:
    Name: serverless-events-observability
    Author: CargoNaut
    SpdxLicenseId: Apache-2.0
    LicenseUrl: LICENSE.txt
    ReadmeUrl: README.md         # this becomes the SAR listing page
    Labels: ['eventbridge', 'observability', 'opensearch', 'serverless']
    SemanticVersion: 1.0.0        # bump before every publish β€” SAR rejects re-use

What turned an internal stack into a publishable one was mostly removing assumptions, and that exercise is worth doing even if you never publish:

Internal versionWhat shipping it forced
Names hardcoded to CargoNaut-*A NamePrefix parameter on every resource.
Managed-domain OpenSearch assumedAn OpenSearchServiceName parameter (es / aoss) and conditional IAM for both.
Region inherited from the stackAn optional OpenSearchRegion override, for a domain in another region.
OpenSearch created alongsideDeliberately not provisioned. You bring the endpoint β€” a one-click deploy should never hand someone a standing OpenSearch bill.
"Everyone knows to do that"A documented post-deploy step: the forwarder role must be added to the domain's access policy or the collection's data access policy.

Publishing is four make targets, and the whole pipeline is one:

$ make create-bucket S3_BUCKET=my-sar-artifacts   # once: artifact bucket + serverlessrepo read policy
$ make release      S3_BUCKET=my-sar-artifacts   # validate β†’ build β†’ package β†’ publish

# then, for a consumer, the entire install is:
$ sam deploy --template-url <SAR-url> --stack-name events-observability \
    --capabilities CAPABILITY_IAM \
    --parameter-overrides OpenSearchEndpoint=… OpenSearchIndex=platform-events \
                          AlertEmail=team@example.com
πŸ§ͺ

sam build --use-container is not optional here. The forwarder depends on pydantic-core, which ships compiled wheels β€” build it on your laptop and you upload macOS binaries that fail at import time inside Lambda, with a stack trace that blames the wrong thing entirely.

A CLAUDE.md for event-driven work

# CLAUDE.md β€” Events

## Conventions
- source: {platform}.{service}.{module}  (dotted, lowercase, STABLE β€” it's a contract)
- detail-type: {Entity}{PastTenseVerb}   (PascalCase, a fact β€” never a command)
- Detail carries ids + org_id + occurred_at; never secrets; add fields, don't repurpose.

## Publishing
- Publish from the SERVICE layer, AFTER the state change commits. Fire-and-forget.
- Never publish from handlers or repositories. One event per real business fact.

## Consuming
- One consumer per concern; match narrowly on source + detail-type.
- Idempotent (key on event id); attach a DLQ; tolerate re-delivery and out-of-order.
- If a consumer publishes, carry a hop-count to prevent loops.

## Analytics
- A catch-all rule mirrors the bus to OpenSearch (Firehose) for audit + dashboards.
  Producers are unaware of it. Never make a service depend on the analytics mirror.

Ask for a feature the event-driven way

State the fact, its name, when it fires, and let the consumer be new β€” don't touch the producer's logic:

> When a shipment is delivered, email the customer β€” event-driven:
  Producer: shipment-service already commits the delivery in
  ShipmentService.deliver_shipment. Add a publish of "ShipmentDelivered"
  (source cargonaut.shipment.router) AFTER the repository write, detail =
  { shipment_id, org_id, occurred_at }. Don't change the delivery logic.
  Consumer: a NEW Lambda in notification-service, EventBridgeRule on
  source=cargonaut.shipment.router, detail-type=[ShipmentDelivered].
  Make it idempotent on the event id and give it a DLQ.
  Show me the template.yaml rule + the handler, and a put-events test.
🧱

Reusable shape: name the fact (past tense) β†’ publish from the service after commit β†’ subscribe with a narrow pattern β†’ idempotent + DLQ β†’ verify with a test event. Same recipe whether the consumer sends an email, meters usage, or forwards to OpenSearch.

Check yourself

Quiz β€” 12 questions

Answer every question, then submit to see your score and the correct answers.

Building event-driven systems on AWS?

JunctionNet ships production event-driven backends every day β€” EventBridge, Lambda, SQS, OpenSearch analytics, Terraform. Reach out for help or a team workshop.

βœ‰οΈ Get in touch ← Back to workshops