From bcd1d5822276651c39c237f5be83668da4fdb726 Mon Sep 17 00:00:00 2001 From: antoinebgit Date: Wed, 19 Aug 2026 13:38:06 +0800 Subject: [PATCH] Add EventBridge to OpenSearch via Data Firehose pattern A catch-all EventBridge rule streams every event on a custom bus to an Amazon OpenSearch Service domain through Amazon Data Firehose, giving full-text search over event payloads within about 60 seconds. - Firehose delivers to a daily-rotated index (events-YYYY-MM-DD) and backs up all documents to S3, with delivery errors logged to CloudWatch Logs. - An optional Lambda transform flattens the EventBridge envelope so detail-type becomes detail_type and detail fields are promoted to the top level, keeping envelope fields authoritative on key collisions. - The Firehose delivery role is authorized on both sides: an identity policy and the domain access policy, since a managed domain evaluates every request against its own policy. - Dashboards access is granted to one or more operator CIDRs, because browsers cannot sign requests with SigV4. --- eventbridge-firehose-opensearch-cdk/README.md | 222 +++++++++ .../architecture.png | Bin 0 -> 69341 bytes .../cdk/.gitignore | 7 + .../cdk/bin/app.ts | 14 + .../cdk/cdk.json | 21 + .../cdk/lib/eventbridge-opensearch-stack.ts | 427 ++++++++++++++++++ .../cdk/package.json | 25 + .../cdk/tsconfig.json | 24 + .../eventbridge-firehose-opensearch-cdk.json | 128 ++++++ .../src/transform/handler.py | 119 +++++ 10 files changed, 987 insertions(+) create mode 100644 eventbridge-firehose-opensearch-cdk/README.md create mode 100644 eventbridge-firehose-opensearch-cdk/architecture.png create mode 100644 eventbridge-firehose-opensearch-cdk/cdk/.gitignore create mode 100644 eventbridge-firehose-opensearch-cdk/cdk/bin/app.ts create mode 100644 eventbridge-firehose-opensearch-cdk/cdk/cdk.json create mode 100644 eventbridge-firehose-opensearch-cdk/cdk/lib/eventbridge-opensearch-stack.ts create mode 100644 eventbridge-firehose-opensearch-cdk/cdk/package.json create mode 100644 eventbridge-firehose-opensearch-cdk/cdk/tsconfig.json create mode 100644 eventbridge-firehose-opensearch-cdk/eventbridge-firehose-opensearch-cdk.json create mode 100644 eventbridge-firehose-opensearch-cdk/src/transform/handler.py diff --git a/eventbridge-firehose-opensearch-cdk/README.md b/eventbridge-firehose-opensearch-cdk/README.md new file mode 100644 index 000000000..6de2b6216 --- /dev/null +++ b/eventbridge-firehose-opensearch-cdk/README.md @@ -0,0 +1,222 @@ +# Amazon EventBridge to Amazon OpenSearch via Amazon Data Firehose + +This pattern gives you **full-text search over every event on an EventBridge bus**, usually within about 60 seconds of emission. A catch-all rule captures all events on a custom bus and streams them to an OpenSearch domain through Amazon Data Firehose, where OpenSearch Dashboards makes them searchable, filterable, and chartable. + +CloudWatch metrics tell you *how many* events flowed. This tells you *what was in them*. + +![Architecture](architecture.png) + +``` +Any event producer + │ + ▼ +EventBridge custom bus + │ Rule: matches ALL events + ▼ +Amazon Data Firehose ──────► Transform Lambda (flattens the envelope) + │ 60s / 1MB buffer + ├──────► OpenSearch domain index: events-YYYY-MM-DD + └──────► S3 bucket all documents + delivery failures +``` + +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/ + +Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Why this pattern + +In an event-driven architecture you eventually need answers to questions metrics cannot give you: + +- What events flowed through the bus in the last five minutes? +- Which producer emitted the most events today? +- Show me every event related to claim `CLM-12345`. +- Is event volume anomalous compared to yesterday? + +Because the rule is a catch-all, you get this for every event on the bus without touching any producer. + +## How it works + +1. A producer calls `PutEvents` on the `event-monitor-bus` custom bus. +2. A catch-all rule matches the event. The pattern is `{"source": [{"prefix": ""}]}` — every EventBridge event carries a `source`, so an empty prefix matches all of them. An entirely empty pattern is rejected by EventBridge. +3. The rule's IAM role calls `firehose:PutRecord` on the delivery stream. +4. Firehose buffers for **60 seconds or 1 MB**, whichever comes first. This is the minimum Firehose allows and it defines the latency of the pattern. +5. A Lambda transform flattens each EventBridge envelope (see below). +6. Firehose signs its request with SigV4 using its delivery role and indexes each document into `events-YYYY-MM-DD`. +7. Every document is also written to the S3 bucket under `events/`. Records that fail to transform or deliver land under `errors/`. + +### What the transform does + +EventBridge delivers a nested envelope. Two things make that awkward to query: + +- `detail-type` contains a hyphen, so it needs escaping in DQL and Lucene queries. +- Business fields sit one level down under `detail`, so every filter reads `detail.claimId`. + +[`src/transform/handler.py`](src/transform/handler.py) renames `detail-type` to `detail_type` and promotes the `detail` keys to the top level: + +```jsonc +// in +{ "source": "agent.claims", "detail-type": "ClaimApproved", "time": "2026-08-17T10:00:00Z", + "detail": { "claimId": "CLM-001", "decision": "approved" } } + +// out +{ "source": "agent.claims", "detail_type": "ClaimApproved", "time": "2026-08-17T10:00:00Z", + "claimId": "CLM-001", "decision": "approved" } +``` + +Envelope fields win on collision: a payload carrying its own `source` key is indexed as `detail_source` rather than masking the real event source. Records that cannot be parsed are returned as `ProcessingFailed`, which routes that one record to the S3 error prefix and lets the rest of the batch through. + +Disable the transform with `-c enableTransform=false` to index the raw envelope instead. + +### Authentication between components + +| Hop | Mechanism | +|---|---| +| Rule → Firehose | Rule target IAM role with `firehose:PutRecord`, `firehose:PutRecordBatch`, scoped to the stream | +| Firehose → OpenSearch | SigV4 with the delivery role, granted on **both** sides: an identity policy on the role and a domain access policy naming it as principal | +| Firehose → Lambda / S3 / Logs | Same delivery role | +| Operator → Dashboards | Anonymous, restricted to the CIDR passed as `dashboardAccessIp` | + +Two details are easy to get wrong here. A managed domain authorizes every request against its own access policy, so an identity policy alone is not enough. And Firehose needs `es:DescribeDomain`, `es:DescribeDomainConfig`, and `es:DescribeDomains` to resolve the domain endpoint before it can deliver anything — `grantIndexWrite()` does not include those. + +## Prerequisites + +- [AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) with sufficient permissions +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cli.html) installed and configured +- [Node.js 20+](https://nodejs.org/en/download/) and npm +- [AWS CDK CLI](https://docs.aws.amazon.com/cdk/v2/guide/getting_started.html) (`npm i -g aws-cdk`), bootstrapped in the target account and Region +- Your public IP, to reach OpenSearch Dashboards: `curl -s https://checkip.amazonaws.com` + +## Deployment + +```bash +git clone https://github.com/aws-samples/serverless-patterns +cd serverless-patterns/eventbridge-firehose-opensearch-cdk/cdk +npm install +cdk deploy -c dashboardAccessIp=$(curl -s https://checkip.amazonaws.com)/32 +``` + +Creating the OpenSearch domain takes 10 to 20 minutes; the rest of the stack is quick. + +Omit `dashboardAccessIp` and the domain stays closed to everything except Firehose. Delivery still works, but you will not be able to open Dashboards. + +Context values: + +| Value | Default | Effect | +|---|---|---| +| `dashboardAccessIp` | none | CIDR(s) granted Dashboards access, e.g. `1.2.3.4/32`. Comma-separate for several: `1.2.3.4/32,5.6.7.8/32` | +| `enableTransform` | `true` | Set `false` to index the raw EventBridge envelope | + +Changing the allowed CIDRs later is cheap. It updates only the domain access policy, so the redeploy takes seconds rather than rebuilding the domain. + +## Testing + +Emit a test event onto the bus: + +```bash +aws events put-events --entries '[{ + "Source": "demo.test", + "DetailType": "TestEvent", + "Detail": "{\"message\": \"Hello OpenSearch\", \"claimId\": \"CLM-001\"}", + "EventBusName": "event-monitor-bus" +}]' +``` + +A successful call returns `"FailedEntryCount": 0`. Wait 60 to 90 seconds for the Firehose buffer to flush. + +Then query the domain directly from the allowlisted IP, using `DomainEndpoint` from the stack outputs: + +```bash +ENDPOINT="" +curl -s "https://$ENDPOINT/events-*/_search?pretty" -H 'Content-Type: application/json' \ + -d '{"query": {"match": {"claimId": "CLM-001"}}}' +``` + +You should see the flattened document, with `detail_type` set to `TestEvent` and `claimId` at the top level. + +Confirm the backup copy reached S3: + +```bash +aws s3 ls "s3:///events/" --recursive +``` + +### If nothing arrives + +Delivery failures are invisible unless you look for them, which is why the stack creates a log group for them. Check `FirehoseLogGroup` from the stack outputs: + +```bash +aws logs tail "" --since 15m +``` + +| Symptom | Likely cause | +|---|---| +| `403` `User: anonymous is not authorized` on your own queries | The address you are calling from is not covered by `dashboardAccessIp` | +| `AccessDeniedException` on the domain | Domain access policy missing the Firehose role, or the role lacks the `es:Describe*` actions | +| Records in S3 under `errors/` but nothing in OpenSearch | Mapping conflict, usually a field indexed as two different types across events | +| Nothing anywhere, `FailedEntryCount: 0` on `put-events` | Rule not matching, or the rule's target role lacks `firehose:PutRecord` | +| Transform errors | Check the `event-monitor-transform` Lambda log group | + +## Setting up OpenSearch Dashboards + +1. Open `DashboardsUrl` from the stack outputs. +2. **Dashboards Management → Index patterns → Create index pattern**. +3. Index pattern name: `events-*`. Time field: `time`. +4. Go to **Discover** to browse events, or **Visualize** to chart them. Useful starting points: event count by `source` as a pie chart, events over time as a line chart, `detail_type` breakdown as a bar chart. + +### Optional: apply an index template + +Without a template, OpenSearch infers mappings dynamically. That works, with two rough edges: string fields become `text` with a `.keyword` subfield, so aggregations need `source.keyword` rather than `source`; and new indices default to one replica, which leaves a single-node cluster permanently yellow because the replica shard can never be assigned. + +Applying a template fixes both. Paste this into **Dev Tools** in Dashboards before sending events: + +```json +PUT _index_template/events +{ + "index_patterns": ["events-*"], + "template": { + "settings": { + "number_of_shards": 1, + "number_of_replicas": 0 + }, + "mappings": { + "properties": { + "id": { "type": "keyword" }, + "source": { "type": "keyword" }, + "detail_type": { "type": "keyword" }, + "time": { "type": "date" }, + "account": { "type": "keyword" }, + "region": { "type": "keyword" }, + "claimId": { "type": "keyword" }, + "status": { "type": "keyword" }, + "agentId": { "type": "keyword" } + } + } + } +} +``` + +The field names here match the flattened output of the transform. If you deploy with `-c enableTransform=false`, map `detail-type` and `detail.*` instead. + +## Cleanup + +```bash +cd cdk +cdk destroy +``` + +The domain, the S3 bucket and its contents, and all log groups are removed. Deleting the domain takes several minutes. + +## Cost considerations + +For a low-volume demo the domain dominates the bill: + +- OpenSearch `t3.small.search`, 1 node: ~$26/month, plus ~$1.60/month for 20 GB gp3 +- Data Firehose: $0.029 per GB ingested +- Lambda transform, EventBridge, and S3: negligible at demo volume + +Destroy the stack when you are done. For production, size the domain to your retention and query load, move to Multi-AZ with a dedicated master, and consider UltraWarm for older indices. + +--- + +Copyright 2026 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/eventbridge-firehose-opensearch-cdk/architecture.png b/eventbridge-firehose-opensearch-cdk/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..8d5aa0f1642caad9d68f4ca8a2a915fd6e74bb43 GIT binary patch literal 69341 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 Amazon Data Firehose -> OpenSearch (Event Monitor pattern) + * + * A catch-all EventBridge rule captures every event on a custom bus and + * streams it to an OpenSearch domain through Amazon Data Firehose, giving + * full-text search over event payloads within about 60 seconds. + * + * Flow: + * Any producer -> EventBridge custom bus + * -> Rule (matches ALL events) + * -> Firehose delivery stream + * -> transform Lambda (flattens the EventBridge envelope) + * -> OpenSearch domain, daily-rotated index + * -> S3 bucket (backup + transform/delivery failures) + * + * Auth model: + * - Rule -> Firehose: the rule target's IAM role holds firehose:PutRecord + * and firehose:PutRecordBatch, scoped to this delivery stream. + * - Firehose -> OpenSearch: SigV4 with the Firehose delivery role. Access + * is granted on both sides -- an identity policy on the role AND a + * domain access policy naming the role as principal. A managed domain + * authorizes every request against its access policy, so the identity + * policy alone is not enough. + * - Firehose -> Lambda / S3 / CloudWatch Logs: same delivery role. + * - Operator -> Dashboards: browsers cannot sign requests with SigV4, so + * Dashboards access is granted to an optional IP CIDR instead. Omit the + * `dashboardAccessIp` context value and the domain stays closed to + * everything except Firehose. + * + * Context values: + * -c dashboardAccessIp=1.2.3.4/32 grant Dashboards access to a CIDR + * -c enableTransform=false index the raw EventBridge envelope + */ +export class EventBridgeOpenSearchStack extends cdk.Stack { + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // Accepts one CIDR or a comma-separated list, so several operator + // addresses can be allowed without widening the range. + const dashboardAccessIpRaw = this.node.tryGetContext('dashboardAccessIp') as string | undefined; + const dashboardAccessIps = dashboardAccessIpRaw + ? dashboardAccessIpRaw + .split(',') + .map((cidr) => cidr.trim()) + .filter((cidr) => cidr.length > 0) + : []; + + // Transform is on by default; it is what makes the data pleasant to query. + const enableTransform = this.node.tryGetContext('enableTransform') !== 'false'; + + const domainName = 'event-monitor'; + const indexName = 'events'; + + // The domain ARN is built by hand rather than read off the Domain + // construct. The domain's own access policy has to name the Firehose + // role, and the Firehose role's policy has to name the domain -- going + // through the construct in both directions would be a circular + // reference. The name is fixed, so the ARN is knowable up front. + const domainArn = this.formatArn({ + service: 'es', + resource: 'domain', + resourceName: domainName, + }); + + // ------------------------------------------------------------------- + // 1. EventBridge custom bus + // ------------------------------------------------------------------- + const eventBus = new events.EventBus(this, 'EventMonitorBus', { + eventBusName: 'event-monitor-bus', + }); + + // ------------------------------------------------------------------- + // 2. S3 backup bucket + // + // Firehose requires an S3 configuration on the OpenSearch destination + // even when backing up only failures, so this bucket is not optional. + // ------------------------------------------------------------------- + const backupBucket = new s3.Bucket(this, 'BackupBucket', { + encryption: s3.BucketEncryption.S3_MANAGED, + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + enforceSSL: true, + lifecycleRules: [ + { + id: 'archive-then-expire', + transitions: [ + { + storageClass: s3.StorageClass.GLACIER, + transitionAfter: cdk.Duration.days(30), + }, + ], + expiration: cdk.Duration.days(365), + }, + ], + // Demo pattern: leave nothing behind on `cdk destroy`. + removalPolicy: cdk.RemovalPolicy.DESTROY, + autoDeleteObjects: true, + }); + + // ------------------------------------------------------------------- + // 3. Firehose delivery role + // + // Created before the domain so the domain access policy can name it. + // Its permissions are attached further down, once the resources it + // needs to reach actually exist. + // ------------------------------------------------------------------- + const firehoseRole = new iam.Role(this, 'FirehoseDeliveryRole', { + assumedBy: new iam.ServicePrincipal('firehose.amazonaws.com'), + description: 'Lets Firehose deliver EventBridge events to OpenSearch and back them up to S3', + }); + + // ------------------------------------------------------------------- + // 4. OpenSearch domain + // ------------------------------------------------------------------- + const domainAccessPolicies = [ + // Firehose delivery role: write documents and read index metadata. + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + principals: [new iam.ArnPrincipal(firehoseRole.roleArn)], + actions: ['es:ESHttpPost', 'es:ESHttpPut', 'es:ESHttpGet', 'es:ESHttpHead'], + resources: [domainArn, `${domainArn}/*`], + }), + ]; + + if (dashboardAccessIps.length > 0) { + // Dashboards runs in a browser, which cannot SigV4-sign requests. + // Anonymous access narrowed to known CIDRs is the standard way in when + // fine-grained access control is off. + domainAccessPolicies.push( + new iam.PolicyStatement({ + effect: iam.Effect.ALLOW, + principals: [new iam.AnyPrincipal()], + actions: ['es:ESHttp*'], + resources: [domainArn, `${domainArn}/*`], + conditions: { + IpAddress: { 'aws:SourceIp': dashboardAccessIps }, + }, + }), + ); + } + + const domain = new opensearch.Domain(this, 'EventMonitorDomain', { + domainName, + version: opensearch.EngineVersion.OPENSEARCH_2_19, + capacity: { + dataNodes: 1, + dataNodeInstanceType: 't3.small.search', + // t3 instance types cannot run Multi-AZ with standby, and CDK + // turns it on by default. Leaving this unset fails synthesis. + multiAzWithStandbyEnabled: false, + }, + ebs: { + volumeSize: 20, + volumeType: ec2.EbsDeviceVolumeType.GP3, + }, + // Single node, so no zone awareness and no dedicated master. + zoneAwareness: { enabled: false }, + enforceHttps: true, + nodeToNodeEncryption: true, + encryptionAtRest: { enabled: true }, + accessPolicies: domainAccessPolicies, + logging: { + appLogEnabled: true, + appLogGroup: new logs.LogGroup(this, 'DomainAppLogs', { + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }), + }, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // ------------------------------------------------------------------- + // 5. Transform Lambda (flattens the EventBridge envelope) + // ------------------------------------------------------------------- + let transformFn: lambda.Function | undefined; + + if (enableTransform) { + transformFn = new lambda.Function(this, 'TransformFunction', { + functionName: 'event-monitor-transform', + runtime: lambda.Runtime.PYTHON_3_12, + handler: 'handler.handler', + code: lambda.Code.fromAsset(path.join(__dirname, '..', '..', 'src', 'transform')), + // Firehose gives a transform 60s before it counts as a failure. + timeout: cdk.Duration.seconds(60), + memorySize: 256, + description: 'Flattens the EventBridge envelope before documents are indexed in OpenSearch', + logGroup: new logs.LogGroup(this, 'TransformLogs', { + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }), + }); + } + + // ------------------------------------------------------------------- + // 6. Firehose error logging + // + // Delivery failures surface nowhere else -- without this, a rejected + // document is invisible. + // ------------------------------------------------------------------- + const firehoseLogGroup = new logs.LogGroup(this, 'FirehoseLogs', { + retention: logs.RetentionDays.ONE_WEEK, + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + const openSearchLogStream = new logs.LogStream(this, 'OpenSearchDeliveryLogStream', { + logGroup: firehoseLogGroup, + logStreamName: 'OpenSearchDelivery', + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + const backupLogStream = new logs.LogStream(this, 'BackupDeliveryLogStream', { + logGroup: firehoseLogGroup, + logStreamName: 'BackupDelivery', + removalPolicy: cdk.RemovalPolicy.DESTROY, + }); + + // ------------------------------------------------------------------- + // 7. Firehose delivery role permissions + // ------------------------------------------------------------------- + // es:ESHttp* writes the documents. The Describe* actions are separate + // and easy to miss: Firehose calls them to resolve the domain endpoint + // before it can deliver anything, and `grantIndexWrite` does not + // include them. + firehoseRole.addToPolicy( + new iam.PolicyStatement({ + sid: 'OpenSearchDescribe', + actions: ['es:DescribeDomain', 'es:DescribeDomainConfig', 'es:DescribeDomains'], + resources: [domainArn], + }), + ); + + firehoseRole.addToPolicy( + new iam.PolicyStatement({ + sid: 'OpenSearchWrite', + actions: ['es:ESHttpPost', 'es:ESHttpPut', 'es:ESHttpGet'], + resources: [domainArn, `${domainArn}/*`], + }), + ); + + backupBucket.grantWrite(firehoseRole); + firehoseRole.addToPolicy( + new iam.PolicyStatement({ + sid: 'S3BackupRead', + actions: ['s3:GetBucketLocation', 's3:ListBucket', 's3:ListBucketMultipartUploads'], + resources: [backupBucket.bucketArn], + }), + ); + + firehoseRole.addToPolicy( + new iam.PolicyStatement({ + sid: 'FirehoseErrorLogging', + actions: ['logs:PutLogEvents'], + resources: [firehoseLogGroup.logGroupArn, `${firehoseLogGroup.logGroupArn}:*`], + }), + ); + + if (transformFn) { + firehoseRole.addToPolicy( + new iam.PolicyStatement({ + sid: 'InvokeTransform', + actions: ['lambda:InvokeFunction', 'lambda:GetFunctionConfiguration'], + resources: [transformFn.functionArn, `${transformFn.functionArn}:*`], + }), + ); + } + + // ------------------------------------------------------------------- + // 8. Firehose delivery stream + // + // L1 by design: the L2 DeliveryStream only accepts destinations that + // implement IDestination, and the only one shipped is S3. OpenSearch + // has to be configured through CfnDeliveryStream. + // ------------------------------------------------------------------- + const deliveryStreamName = 'event-monitor-stream'; + + const cfnStream = new firehose.CfnDeliveryStream(this, 'EventDeliveryStream', { + deliveryStreamName, + deliveryStreamType: 'DirectPut', + amazonopensearchserviceDestinationConfiguration: { + // Combined with OneDay rotation this yields events-YYYY-MM-DD. + indexName, + indexRotationPeriod: 'OneDay', + // domainArn and clusterEndpoint are mutually exclusive. + domainArn: domain.domainArn, + roleArn: firehoseRole.roleArn, + // 60s / 1MB is the floor Firehose allows, and it sets the + // end-to-end latency of this pattern. + bufferingHints: { + intervalInSeconds: 60, + sizeInMBs: 1, + }, + retryOptions: { + durationInSeconds: 300, + }, + // AllDocuments keeps a durable copy of everything indexed, which + // makes the S3 bucket an audit trail rather than just a dead + // letter destination. + s3BackupMode: 'AllDocuments', + s3Configuration: { + bucketArn: backupBucket.bucketArn, + roleArn: firehoseRole.roleArn, + prefix: 'events/', + errorOutputPrefix: 'errors/', + bufferingHints: { + intervalInSeconds: 300, + sizeInMBs: 5, + }, + compressionFormat: 'GZIP', + cloudWatchLoggingOptions: { + enabled: true, + logGroupName: firehoseLogGroup.logGroupName, + logStreamName: backupLogStream.logStreamName, + }, + }, + cloudWatchLoggingOptions: { + enabled: true, + logGroupName: firehoseLogGroup.logGroupName, + logStreamName: openSearchLogStream.logStreamName, + }, + processingConfiguration: transformFn + ? { + enabled: true, + processors: [ + { + type: 'Lambda', + parameters: [ + { parameterName: 'LambdaArn', parameterValue: transformFn.functionArn }, + { parameterName: 'RoleArn', parameterValue: firehoseRole.roleArn }, + // Lambda processor buffer must stay within 0.2-3 MB. + { parameterName: 'BufferSizeInMBs', parameterValue: '1' }, + { parameterName: 'BufferIntervalInSeconds', parameterValue: '60' }, + { parameterName: 'NumberOfRetries', parameterValue: '3' }, + ], + }, + ], + } + : undefined, + }, + }); + + // The stream is only usable once the role's policies are attached and + // the domain access policy has been applied. Neither shows up as a + // CloudFormation reference, so the ordering has to be explicit. + cfnStream.node.addDependency(firehoseRole); + cfnStream.node.addDependency(domain); + + // ------------------------------------------------------------------- + // 9. Catch-all EventBridge rule + // ------------------------------------------------------------------- + // Wrapping the L1 stream as an L2 lets the event target construct + // build the rule's IAM role for us. + const deliveryStream = firehose.DeliveryStream.fromDeliveryStreamArn( + this, + 'ImportedDeliveryStream', + cfnStream.attrArn, + ); + + const rule = new events.Rule(this, 'CatchAllRule', { + eventBus, + ruleName: 'event-monitor-catch-all', + description: 'Captures every event on the bus and streams it to OpenSearch via Data Firehose', + // Every event carries a source, so an empty prefix matches all of + // them. An empty event pattern is rejected by EventBridge. + eventPattern: { + source: events.Match.prefix(''), + }, + }); + + rule.addTarget(new targets.FirehoseDeliveryStream(deliveryStream)); + + // Imported constructs carry no dependency edge of their own. + rule.node.addDependency(cfnStream); + + // ------------------------------------------------------------------- + // Outputs + // ------------------------------------------------------------------- + new cdk.CfnOutput(this, 'DashboardsUrl', { + value: `https://${domain.domainEndpoint}/_dashboards/`, + description: + dashboardAccessIps.length > 0 + ? `OpenSearch Dashboards URL (reachable from: ${dashboardAccessIps.join(', ')})` + : 'OpenSearch Dashboards URL (no public access granted -- redeploy with -c dashboardAccessIp=YOUR_IP/32)', + }); + + new cdk.CfnOutput(this, 'DomainEndpoint', { + value: domain.domainEndpoint, + description: 'OpenSearch domain endpoint', + }); + + new cdk.CfnOutput(this, 'EventBusName', { + value: eventBus.eventBusName, + description: 'EventBridge custom bus being monitored', + }); + + new cdk.CfnOutput(this, 'DeliveryStreamName', { + value: deliveryStreamName, + description: 'Firehose delivery stream carrying events to OpenSearch', + }); + + new cdk.CfnOutput(this, 'BackupBucketName', { + value: backupBucket.bucketName, + description: 'S3 bucket holding the event backup and any delivery failures', + }); + + new cdk.CfnOutput(this, 'FirehoseLogGroup', { + value: firehoseLogGroup.logGroupName, + description: 'CloudWatch log group for Firehose delivery errors', + }); + + new cdk.CfnOutput(this, 'IndexPattern', { + value: `${indexName}-*`, + description: 'Index pattern to create in OpenSearch Dashboards (time field: time)', + }); + } +} diff --git a/eventbridge-firehose-opensearch-cdk/cdk/package.json b/eventbridge-firehose-opensearch-cdk/cdk/package.json new file mode 100644 index 000000000..cf7492011 --- /dev/null +++ b/eventbridge-firehose-opensearch-cdk/cdk/package.json @@ -0,0 +1,25 @@ +{ + "name": "eventbridge-firehose-opensearch-cdk", + "version": "1.0.0", + "description": "Stream all EventBridge events to OpenSearch via Amazon Data Firehose for near real-time monitoring", + "bin": { + "app": "bin/app.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk", + "deploy": "cdk deploy", + "destroy": "cdk destroy" + }, + "devDependencies": { + "@types/node": "20.14.9", + "aws-cdk": "2.1136.0", + "ts-node": "10.9.2", + "typescript": "5.5.3" + }, + "dependencies": { + "aws-cdk-lib": "2.264.0", + "constructs": "10.8.1" + } +} diff --git a/eventbridge-firehose-opensearch-cdk/cdk/tsconfig.json b/eventbridge-firehose-opensearch-cdk/cdk/tsconfig.json new file mode 100644 index 000000000..b1eaa510e --- /dev/null +++ b/eventbridge-firehose-opensearch-cdk/cdk/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["es2022"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": ["./node_modules/@types"] + }, + "exclude": ["node_modules", "cdk.out"] +} diff --git a/eventbridge-firehose-opensearch-cdk/eventbridge-firehose-opensearch-cdk.json b/eventbridge-firehose-opensearch-cdk/eventbridge-firehose-opensearch-cdk.json new file mode 100644 index 000000000..f9979627a --- /dev/null +++ b/eventbridge-firehose-opensearch-cdk/eventbridge-firehose-opensearch-cdk.json @@ -0,0 +1,128 @@ +{ + "title": "Amazon EventBridge to Amazon OpenSearch via Amazon Data Firehose", + "description": "Monitor every event flowing through an EventBridge bus in near real time by streaming it to OpenSearch through Amazon Data Firehose, with daily index rotation, payload flattening, and an S3 backup of all documents.", + "language": "TypeScript", + "level": "200", + "framework": "CDK", + "patternArch": { + "icon1": { + "x": 10, + "y": 50, + "service": "eventbridge", + "label": "Amazon EventBridge" + }, + "icon2": { + "x": 40, + "y": 50, + "service": "kinesis-firehose", + "label": "Amazon Data Firehose" + }, + "icon3": { + "x": 40, + "y": 15, + "service": "lambda", + "label": "Transform Lambda" + }, + "icon4": { + "x": 75, + "y": 50, + "service": "opensearch", + "label": "Amazon OpenSearch" + }, + "icon5": { + "x": 75, + "y": 15, + "service": "s3", + "label": "Amazon S3" + }, + "line1": { + "from": "icon1", + "to": "icon2", + "label": "All events" + }, + "line2": { + "from": "icon2", + "to": "icon3", + "label": "Flatten" + }, + "line3": { + "from": "icon2", + "to": "icon4", + "label": "Index" + }, + "line4": { + "from": "icon2", + "to": "icon5", + "label": "Backup" + } + }, + "introBox": { + "headline": "How it works", + "text": [ + "This pattern gives you full-text search over every event on an Amazon EventBridge bus, usually within about 60 seconds of the event being emitted. CloudWatch metrics tell you how many events flowed; this tells you what was in them.", + "A catch-all EventBridge rule matches every event on a custom bus and sends it to an Amazon Data Firehose delivery stream. The rule pattern matches on a source prefix of an empty string, because every EventBridge event carries a source and an entirely empty pattern is rejected.", + "Firehose buffers for 60 seconds or 1 MB, whichever comes first. That buffer is the floor Firehose allows and it sets the end-to-end latency of the pattern.", + "Before indexing, a Lambda transform flattens the EventBridge envelope: detail-type is renamed to detail_type so it needs no escaping in queries, and the fields inside detail are promoted to the top level so a dashboard can filter on claimId rather than detail.claimId. Envelope fields win on collision, so a business payload carrying its own source key is indexed as detail_source instead of masking the real event source.", + "Documents land in a daily-rotated index, events-YYYY-MM-DD, and every document is also written to an S3 bucket, which makes that bucket an audit trail rather than only a dead letter destination.", + "Firehose authenticates to OpenSearch with SigV4 using its delivery role. A managed domain authorizes each request against its own access policy, so the role is granted access on both sides: an identity policy on the role and a domain access policy naming that role as principal." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/eventbridge-firehose-opensearch-cdk", + "templateURL": "serverless-patterns/eventbridge-firehose-opensearch-cdk", + "projectFolder": "eventbridge-firehose-opensearch-cdk", + "templateFile": "cdk/lib/eventbridge-opensearch-stack.ts" + } + }, + "resources": { + "bullets": [ + { + "text": "Loading streaming data into Amazon OpenSearch Service with Amazon Data Firehose", + "link": "https://docs.aws.amazon.com/opensearch-service/latest/developerguide/integrations-fh.html" + }, + { + "text": "Amazon Data Firehose data transformation with AWS Lambda", + "link": "https://docs.aws.amazon.com/firehose/latest/dev/data-transformation.html" + }, + { + "text": "Amazon EventBridge event patterns", + "link": "https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-event-patterns.html" + }, + { + "text": "Identity and Access Management in Amazon OpenSearch Service", + "link": "https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ac.html" + }, + { + "text": "Audit AWS service events with Amazon EventBridge and Amazon Data Firehose", + "link": "https://aws.amazon.com/blogs/big-data/audit-aws-service-events-with-amazon-eventbridge-and-amazon-kinesis-data-firehose/" + } + ] + }, + "deploy": { + "text": [ + "cd cdk", + "npm install", + "cdk deploy -c dashboardAccessIp=YOUR_IP/32" + ] + }, + "testing": { + "headline": "Testing", + "text": [ + "See the GitHub repo README.md for detailed testing instructions.", + "Emit a test event onto the bus: aws events put-events --entries '[{\"Source\":\"demo.test\",\"DetailType\":\"TestEvent\",\"Detail\":\"{\\\"message\\\":\\\"Hello OpenSearch\\\",\\\"claimId\\\":\\\"CLM-001\\\"}\",\"EventBusName\":\"event-monitor-bus\"}]'", + "Wait 60 to 90 seconds for the Firehose buffer to flush, then open the DashboardsUrl from the stack outputs and create an index pattern of events-* with time as the time field." + ] + }, + "cleanup": { + "headline": "Cleanup", + "text": ["cd cdk", "cdk destroy"] + }, + "authors": [ + { + "name": "Antoine Boucherie", + "bio": "Principal Solutions Architect, AWS Global Financial Services", + "linkedin": "antoineboucherie" + } + ] +} diff --git a/eventbridge-firehose-opensearch-cdk/src/transform/handler.py b/eventbridge-firehose-opensearch-cdk/src/transform/handler.py new file mode 100644 index 000000000..d112260e4 --- /dev/null +++ b/eventbridge-firehose-opensearch-cdk/src/transform/handler.py @@ -0,0 +1,119 @@ +""" +Amazon Data Firehose transform: flatten the EventBridge envelope. + +EventBridge delivers events to Firehose in their raw envelope form: + + { + "version": "0", + "id": "e7c9...", + "detail-type": "ClaimApproved", + "source": "agent.claims-processor", + "account": "111122223333", + "time": "2026-08-17T10:00:00Z", + "region": "us-east-1", + "resources": [], + "detail": { "claimId": "CLM-001", "decision": "approved" } + } + +Two things make that awkward to query in OpenSearch: + +1. ``detail-type`` contains a hyphen, so it needs escaping in DQL/Lucene + queries and cannot be referenced directly in some aggregations. +2. Business fields are nested one level down under ``detail``, so every + dashboard filter has to be written as ``detail.claimId`` instead of + ``claimId``. + +This transform renames ``detail-type`` to ``detail_type`` and promotes the +``detail`` keys to the top level, so a search for ``claimId: "CLM-001"`` +works directly. + +Envelope fields win on collision: if a payload contains its own ``source`` +key it is indexed as ``detail_source`` rather than overwriting the +EventBridge envelope value. Without this guard a business payload could +silently mask the real event source. +""" + +import base64 +import json +import logging + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +# Envelope keys that a business payload must never overwrite. +RESERVED_KEYS = frozenset( + { + "source", + "detail_type", + "time", + "account", + "region", + "id", + "resources", + "version", + } +) + + +def flatten(payload: dict) -> dict: + """Flatten one EventBridge envelope into a single-level document.""" + flat = { + "id": payload.get("id"), + "source": payload.get("source"), + "detail_type": payload.get("detail-type"), + "time": payload.get("time"), + "account": payload.get("account"), + "region": payload.get("region"), + "resources": payload.get("resources", []), + } + + detail = payload.get("detail") + if isinstance(detail, dict): + for key, value in detail.items(): + # Prefix rather than overwrite so envelope metadata stays truthful. + flat[f"detail_{key}" if key in RESERVED_KEYS else key] = value + elif detail is not None: + # Non-object detail (string, list, number) still needs to be indexed. + flat["detail"] = detail + + # Drop keys the producer never set so OpenSearch does not index nulls. + return {k: v for k, v in flat.items() if v is not None} + + +def handler(event, context): + output = [] + + for record in event["records"]: + record_id = record["recordId"] + try: + raw = base64.b64decode(record["data"]) + payload = json.loads(raw) + + if not isinstance(payload, dict): + raise ValueError(f"expected a JSON object, got {type(payload).__name__}") + + document = json.dumps(flatten(payload)) + "\n" + + output.append( + { + "recordId": record_id, + "result": "Ok", + "data": base64.b64encode(document.encode("utf-8")).decode("utf-8"), + } + ) + except Exception as exc: + # ProcessingFailed routes just this record to the S3 error prefix + # and lets the rest of the batch through. + logger.warning("Record %s failed to transform: %s", record_id, exc) + output.append( + { + "recordId": record_id, + "result": "ProcessingFailed", + "data": record["data"], + } + ) + + ok = sum(1 for r in output if r["result"] == "Ok") + logger.info("Transformed %d/%d records", ok, len(output)) + + return {"records": output}