AWS CloudWatch Integration
Forward logs and metrics from AWS CloudWatch into Operyn for AI-powered incident detection, diagnosis, and remediation.
Architecture
CloudWatch Log Groups
│
▼
Subscription Filter
│
▼
Lambda Forwarder ──► POST /events/logs/batch ──► Operyn Ingestion
│ │
│ Normalise → Index → Enqueue
│ │
▼ ▼
CloudWatch Metrics Incident Engine
│ (detect + diagnose)
▼
Metric Stream → Firehose
│
▼
Lambda Forwarder ──► POST /events/metrics ──► Operyn Ingestion
How it works
- CloudWatch Logs are forwarded via a Subscription Filter to a Lambda function.
- The Lambda decodes the compressed CloudWatch payload, maps each log event to Operyn's
RawLogInputformat, and POSTs them to the ingestion API. - CloudWatch Metrics (optional, phase 2) use Metric Streams delivered via Kinesis Data Firehose to a second Lambda that maps metrics to
RawMetricInput. - Operyn normalises, indexes (OpenSearch), and enqueues events for incident detection.
Prerequisites
- An AWS account with CloudWatch Log Groups you want to monitor.
- Operyn running and accessible from your AWS environment.
- An Operyn API key (available in Settings → Organization in the Dashboard).
- AWS CLI or Terraform/CloudFormation (for manual setup).
Automated Deployment (Recommended)
For users on Pro or Enterprise plans, Operyn provides a one-click deployment using a pre-configured CloudFormation stack.
- Navigate to Settings → Integrations in the Operyn Dashboard.
- Locate the AWS CloudWatch card under "Ingestion Sources".
- Select your AWS region.
- Ensure your API Key is generated in the "Organization" settings.
- Click Deploy. This will open the AWS Console with the stack parameters (API Key, Ingestion URL) pre-filled.
- Acknowledge the IAM resource creation and click Create Stack.
This stack automatically provisions:
- An IAM Role for the forwarder.
- A Node.js Lambda function configured with your API key.
- The necessary permissions for CloudWatch to trigger the Lambda.
Manual Setup
For Starter plan users or custom environments, you can set up the forwarder manually.
Step 1: Identify Log Groups
Decide which CloudWatch Log Groups to forward. Common choices:
| Log Group Pattern | Source |
|---|---|
/aws/eks/<cluster>/cluster | EKS control plane |
/aws/containerinsights/<cluster>/application | EKS application logs |
/aws/ecs/<service> | ECS task logs |
/aws/lambda/<function> | Lambda function logs |
/aws/apigateway/<api-id> | API Gateway access logs |
/aws/rds/instance/<id>/error | RDS error logs |
Step 2: Create the Lambda Forwarder
IAM Role
Create an IAM role for the Lambda with the following permissions:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents"
],
"Resource": "arn:aws:logs:*:*:*"
}
]
}
The Lambda also needs network access to your Operyn ingestion endpoint.
Lambda Handler (Node.js 20)
Create a Lambda function with the following handler. This decodes the CloudWatch Logs payload, maps each event to Operyn's ingestion format, and sends a batch request.
import { gunzipSync } from 'node:zlib';
const OPERYN_INGEST_URL = process.env.OPERYN_INGEST_URL;
const OPERYN_API_KEY = process.env.OPERYN_API_KEY;
const OPERYN_ORGANIZATION_ID = process.env.OPERYN_ORGANIZATION_ID;
const SERVICE_MAP = JSON.parse(process.env.SERVICE_MAP || '{}');
function deriveServiceName(logGroup) {
if (SERVICE_MAP[logGroup]) return SERVICE_MAP[logGroup];
const parts = logGroup.split('/');
// /aws/lambda/billing-service → billing-service
// /aws/ecs/api-gateway → api-gateway
return parts[parts.length - 1] || 'unknown';
}
function extractLevel(message) {
const upper = message.toUpperCase();
if (upper.includes('FATAL')) return 'fatal';
if (upper.includes('ERROR')) return 'error';
if (upper.includes('WARN')) return 'warn';
if (upper.includes('DEBUG')) return 'debug';
return 'info';
}
export const handler = async (event) => {
const compressed = Buffer.from(event.awslogs.data, 'base64');
const payload = JSON.parse(gunzipSync(compressed).toString('utf-8'));
const { logGroup, logStream, logEvents } = payload;
const service = deriveServiceName(logGroup);
const body = logEvents.map((le) => ({
service,
level: extractLevel(le.message),
message: le.message,
timestamp: new Date(le.timestamp).toISOString(),
metadata: {
source: 'cloudwatch',
logGroup,
logStream,
awsRegion: process.env.AWS_REGION,
},
}));
const response = await fetch(`${OPERYN_INGEST_URL}/events/logs/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': OPERYN_API_KEY,
'x-organization-id': OPERYN_ORGANIZATION_ID,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Ingestion failed (${response.status}): ${text}`);
}
const result = await response.json();
console.log(`Forwarded ${result.accepted} events from ${logGroup}`);
return result;
};
Environment Variables
| Variable | Description |
|---|---|
OPERYN_INGEST_URL | Base URL of your Operyn ingestion service, e.g. https://ingest.operyn.example.com |
OPERYN_API_KEY | API key for the ingestion service (matches OPERYN_API_KEY on the server) |
OPERYN_ORGANIZATION_ID | Your Operyn Organization ID used for multi-tenant data routing |
SERVICE_MAP | Optional JSON mapping of log group paths to service names, e.g. {"/aws/lambda/billing": "billing-service"} |
Step 3: Create Subscription Filters
For each log group you want to forward, create a subscription filter pointing to the Lambda.
AWS CLI
# Allow CloudWatch to invoke the Lambda
aws lambda add-permission \
--function-name operyn-log-forwarder \
--statement-id cloudwatch-invoke \
--action lambda:InvokeFunction \
--principal logs.amazonaws.com \
--source-arn "arn:aws:logs:us-east-1:123456789012:log-group:/aws/ecs/billing-service:*"
# Create the subscription filter
aws logs put-subscription-filter \
--log-group-name "/aws/ecs/billing-service" \
--filter-name "operyn-forwarder" \
--filter-pattern "" \
--destination-arn "arn:aws:lambda:us-east-1:123456789012:function:operyn-log-forwarder"
Terraform
resource "aws_lambda_permission" "cloudwatch" {
for_each = toset(var.log_groups)
statement_id = "cloudwatch-${md5(each.value)}"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.operyn_forwarder.function_name
principal = "logs.amazonaws.com"
source_arn = "arn:aws:logs:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:log-group:${each.value}:*"
}
resource "aws_cloudwatch_log_subscription_filter" "operyn" {
for_each = toset(var.log_groups)
name = "operyn-forwarder"
log_group_name = each.value
filter_pattern = ""
destination_arn = aws_lambda_function.operyn_forwarder.arn
}
Step 4: Verify Ingestion
Once the subscription is active, verify logs are flowing into Operyn.
Check via curl
# Manually send a test log
curl -X POST https://ingest.operyn.example.com/events/logs \
-H "Content-Type: application/json" \
-H "x-api-key: YOUR_API_KEY" \
-H "x-organization-id: YOUR_ORG_ID" \
-d '{
"service": "billing-service",
"level": "error",
"message": "Test event from CloudWatch integration verification",
"metadata": { "source": "cloudwatch", "env": "prod" }
}'
Confirm in the Dashboard
- Open the Operyn Dashboard.
- Navigate to the Incidents page.
- If events match an incident detection rule, you should see a new incident appear.
- Check OpenSearch for indexed log documents in the
operyn-logsindex.
Step 5: CloudWatch Metrics (Phase 2)
For metrics integration, use CloudWatch Metric Streams delivered via Kinesis Data Firehose to a Lambda that maps each data point to Operyn's RawMetricInput format.
Mapping
| CloudWatch Field | Operyn Field |
|---|---|
| Namespace + MetricName | service + name (e.g. ecs/CPUUtilization) |
| Value (Average, Sum, etc.) | value |
| Unit | unit |
| Timestamp | timestamp |
| Dimensions | labels (e.g. { "ClusterName": "prod", "ServiceName": "billing" }) |
Conceptual Lambda
export const handler = async (event) => {
for (const record of event.records) {
const data = JSON.parse(Buffer.from(record.data, 'base64').toString());
const body = {
service: `${data.namespace}/${data.metric_name}`.toLowerCase().replace(/\//g, '-'),
name: data.metric_name,
value: data.value.sum ?? data.value.average ?? data.value.max ?? 0,
unit: data.unit,
timestamp: new Date(data.timestamp).toISOString(),
labels: data.dimensions || {},
};
await fetch(`${process.env.OPERYN_INGEST_URL}/events/metrics`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.OPERYN_API_KEY,
'x-organization-id': process.env.OPERYN_ORGANIZATION_ID,
},
body: JSON.stringify(body),
});
}
};
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Lambda invoked but no events in Operyn | Ingestion URL unreachable | Check network/VPC config; verify Lambda can reach Operyn |
401 Unauthorized response | Wrong API key | Verify OPERYN_API_KEY matches between Lambda env and ingestion service |
| Events ingested but no incidents | Detection rules not matching | Review incident engine thresholds and detection patterns |
| Lambda timeout | Payload too large or slow network | Increase Lambda timeout; consider batching smaller windows |
Next Steps
- Ingestion API Reference — full endpoint documentation.
- Quickstart — set up Operyn locally and send your first event.
- Concepts: Incidents — how events become incidents.