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

  1. CloudWatch Logs are forwarded via a Subscription Filter to a Lambda function.
  2. The Lambda decodes the compressed CloudWatch payload, maps each log event to Operyn's RawLogInput format, and POSTs them to the ingestion API.
  3. CloudWatch Metrics (optional, phase 2) use Metric Streams delivered via Kinesis Data Firehose to a second Lambda that maps metrics to RawMetricInput.
  4. 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).

For users on Pro or Enterprise plans, Operyn provides a one-click deployment using a pre-configured CloudFormation stack.

  1. Navigate to Settings → Integrations in the Operyn Dashboard.
  2. Locate the AWS CloudWatch card under "Ingestion Sources".
  3. Select your AWS region.
  4. Ensure your API Key is generated in the "Organization" settings.
  5. Click Deploy. This will open the AWS Console with the stack parameters (API Key, Ingestion URL) pre-filled.
  6. 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 PatternSource
/aws/eks/<cluster>/clusterEKS control plane
/aws/containerinsights/<cluster>/applicationEKS 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>/errorRDS 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

VariableDescription
OPERYN_INGEST_URLBase URL of your Operyn ingestion service, e.g. https://ingest.operyn.example.com
OPERYN_API_KEYAPI key for the ingestion service (matches OPERYN_API_KEY on the server)
OPERYN_ORGANIZATION_IDYour Operyn Organization ID used for multi-tenant data routing
SERVICE_MAPOptional 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

  1. Open the Operyn Dashboard.
  2. Navigate to the Incidents page.
  3. If events match an incident detection rule, you should see a new incident appear.
  4. Check OpenSearch for indexed log documents in the operyn-logs index.

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 FieldOperyn Field
Namespace + MetricNameservice + name (e.g. ecs/CPUUtilization)
Value (Average, Sum, etc.)value
Unitunit
Timestamptimestamp
Dimensionslabels (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

SymptomCauseFix
Lambda invoked but no events in OperynIngestion URL unreachableCheck network/VPC config; verify Lambda can reach Operyn
401 Unauthorized responseWrong API keyVerify OPERYN_API_KEY matches between Lambda env and ingestion service
Events ingested but no incidentsDetection rules not matchingReview incident engine thresholds and detection patterns
Lambda timeoutPayload too large or slow networkIncrease Lambda timeout; consider batching smaller windows

Next Steps