> ## Documentation Index
> Fetch the complete documentation index at: https://docs.aciona.me/llms.txt
> Use this file to discover all available pages before exploring further.

# AWS CloudWatch

> Connect CloudWatch alarms to aciona.me through SNS and a transform Lambda.

## Overview

CloudWatch publishes alarm transitions to an **SNS topic**. A **Lambda** subscribed to that topic converts the notification into aciona.me's canonical format and posts it to the alert source.

```mermaid theme={null}
flowchart LR
  A["CloudWatch Alarm"] -->|"AlarmActions / OKActions"| B["SNS topic"]
  B --> C["Transform Lambda"]
  C -->|"POST + X-Aciona-Token"| D["aciona.me"]
```

|                     |                          |
| ------------------- | ------------------------ |
| **Source type**     | `CloudWatch`             |
| **Mechanism**       | SNS + Lambda             |
| **Correlation**     | `alarmArn`               |
| **Auto-resolution** | ✅ (requires `OKActions`) |

## Prerequisites

* AWS permission to create SNS topics, Lambda functions and modify alarms.
* A service created in aciona.me and linked to a team with an active schedule.

## 1. Create the alert source in aciona.me

Under **Alert sources**, create a source of type **CloudWatch**. Copy the URL and the token.

## 2. Create the SNS topic

```bash theme={null}
aws sns create-topic --name aciona-me-cloudwatch
```

## 3. Deploy the transform Lambda

Runtime **Node.js 20+**, handler `index.handler`, no external dependencies.

Environment variables:

| Variable             | Value                                                |
| -------------------- | ---------------------------------------------------- |
| `ACIONA_INGRESS_URL` | `https://ingress.aciona.me/webhooks/<alertSourceId>` |
| `ACIONA_TOKEN`       | The source token                                     |
| `REGION`             | The Lambda's region, e.g. `us-east-1`                |

IAM role: `AWSLambdaBasicExecutionRole`.

<Warning>
  In production, keep the token in **SSM Parameter Store (SecureString)** or **Secrets Manager** and read it on cold start — do not leave the value in plain text in the Lambda configuration.
</Warning>

<Accordion title="Lambda code" icon="code">
  ```javascript index.mjs theme={null}
  import https from 'node:https';
  import { URL } from 'node:url';

  const SEVERITY_MAP = {
    crit: 'critical', critical: 'critical', sev1: 'critical', p1: 'critical',
    high: 'high', sev2: 'high', p2: 'high',
    warn: 'warning', warning: 'warning', sev3: 'warning', p3: 'warning',
    info: 'info', low: 'info', sev4: 'info', p4: 'info',
  };

  const str = (v) => (v === null || v === undefined ? '' : String(v));

  function parseRegionFromArn(arn) {
    const parts = str(arn).split(':');
    return parts.length >= 4 ? parts[3] : '';
  }

  function resolveSeverity(alarmDescription, alarmName) {
    const m = str(alarmDescription).match(/\[severity:([a-zA-Z0-9]+)\]/);
    if (m) return SEVERITY_MAP[m[1].toLowerCase()] || 'warning';
    const name = str(alarmName).toLowerCase();
    for (const k of ['critical', 'high', 'warning', 'info']) {
      if (name.includes(k)) return k;
    }
    return 'warning';
  }

  function extractServiceHint(trigger, alarmName) {
    if (!trigger || typeof trigger !== 'object') return str(alarmName);
    const dims = trigger.Dimensions || {};
    const byNamespace = {
      'AWS/ECS': ['ServiceName', 'ClusterName'],
      'AWS/RDS': ['DBClusterIdentifier', 'DBInstanceIdentifier'],
      'AWS/DynamoDB': ['TableName'],
      'AWS/Lambda': ['FunctionName'],
      'AWS/EC2': ['InstanceId'],
      'AWS/ApplicationElb': ['LoadBalancer'],
      'AWS/ApiGateway': ['ApiName'],
      'AWS/SQS': ['QueueName'],
      'AWS/S3': ['BucketName'],
    };
    for (const k of byNamespace[trigger.Namespace] || []) {
      if (dims[k]) return str(dims[k]);
    }
    const ns = str(trigger.Namespace).replace(/^(AWS\/|ECS\/|EKS\/)/, '');
    return ns || str(alarmName);
  }

  function toCanonical(msg) {
    const alarmArn = str(msg.AlarmArn);
    const alarmName = str(msg.AlarmName);
    const region = parseRegionFromArn(alarmArn) || process.env.REGION || '';
    const trigger = msg.Trigger || null;
    return {
      schema: 'aciona.cloudwatch.v1',
      source: 'aws_cloudwatch',
      alarmName,
      alarmArn,
      awsAccountId: str(msg.AWSAccountId),
      region,
      state: str(msg.NewStateValue).toUpperCase(),
      oldState: str(msg.OldStateValue).toUpperCase(),
      reason: str(msg.NewStateReason),
      stateChangeTime: str(msg.StateChangeTime),
      severity: resolveSeverity(msg.AlarmDescription, alarmName),
      service: extractServiceHint(trigger, alarmName),
      dimensions: trigger?.Dimensions || null,
      trigger: trigger
        ? {
            metricName: trigger.MetricName,
            namespace: trigger.Namespace,
            statistic: trigger.Statistic,
            period: trigger.Period,
            evaluationPeriods: trigger.EvaluationPeriods,
            comparisonOperator: trigger.ComparisonOperator,
            threshold: trigger.Threshold,
          }
        : null,
      alarmDescription: str(msg.AlarmDescription),
      url: region && alarmName
        ? `https://console.aws.amazon.com/cloudwatch/home?region=${region}#alarmsAlarm:alarmName=${encodeURIComponent(alarmName)}`
        : '',
    };
  }

  function postToIngress(payload) {
    return new Promise((resolve, reject) => {
      const url = new URL(process.env.ACIONA_INGRESS_URL);
      const body = JSON.stringify(payload);
      const req = https.request(
        {
          method: 'POST',
          hostname: url.hostname,
          path: url.pathname + url.search,
          headers: {
            'Content-Type': 'application/json',
            'Content-Length': Buffer.byteLength(body),
            'X-Aciona-Token': process.env.ACIONA_TOKEN,
          },
        },
        (res) => {
          let data = '';
          res.on('data', (c) => (data += c));
          res.on('end', () => {
            if (res.statusCode >= 200 && res.statusCode < 300) resolve(data);
            else reject(new Error(`HTTP ${res.statusCode}: ${data}`));
          });
        },
      );
      req.on('error', reject);
      req.write(body);
      req.end();
    });
  }

  export const handler = async (event) => {
    const results = [];
    for (const record of event.Records || []) {
      try {
        const canonical = toCanonical(JSON.parse(record.Sns.Message));
        await postToIngress(canonical);
        results.push({ ok: true, alarmArn: canonical.alarmArn });
      } catch (err) {
        console.error('[aciona.me] SNS record failed', err.message);
        results.push({ ok: false, error: err.message });
      }
    }
    if (results.length > 0 && results.every((r) => !r.ok)) {
      throw new Error('All records failed');
    }
    return { batch: results };
  };
  ```
</Accordion>

## 4. Subscribe the Lambda to the topic

```bash theme={null}
aws sns subscribe \
  --topic-arn <topic-arn> \
  --protocol lambda \
  --notification-endpoint <lambda-arn>
```

Harden the topic policy with `aws:SourceAccount` and, where possible, `aws:SourceArn`, so only CloudWatch in your account can publish.

## 5. Configure the alarm with AlarmActions **and** OKActions

```bash theme={null}
aws cloudwatch put-metric-alarm \
  --alarm-name "payments-api-5xx-high" \
  --alarm-description "5xx above 5/s for 1 minute [severity:critical]" \
  --alarm-actions <topic-arn> \
  --ok-actions <topic-arn> \
  ...
```

<Warning>
  **`OKActions` is required for auto-resolution.** Without it, CloudWatch never emits the `OK` transition and the incident stays open indefinitely. This is the most-forgotten step in the whole integration.
</Warning>

## 6. Test

```bash theme={null}
aws cloudwatch set-alarm-state \
  --alarm-name "payments-api-5xx-high" \
  --state-value ALARM \
  --state-reason "aciona.me manual test"
```

Confirm the incident in the dashboard. Then force the recovery:

```bash theme={null}
aws cloudwatch set-alarm-state \
  --alarm-name "payments-api-5xx-high" \
  --state-value OK \
  --state-reason "aciona.me manual test"
```

The incident should be resolved automatically.

## How fields are translated

| Alarm state         | Effect                            |
| ------------------- | --------------------------------- |
| `ALARM`             | Creates or aggregates an incident |
| `OK`                | Resolves automatically            |
| `INSUFFICIENT_DATA` | Ignored                           |

CloudWatch has no native severity. The Lambda resolves an alias in layers:

1. A marker in `AlarmDescription`: `[severity:critical]`
2. A keyword in the alarm name (`critical`, `high`, `warning` or `info`)
3. Default: `warning`

## Troubleshooting

<AccordionGroup>
  <Accordion title="The incident opens but never closes" icon="triangle-alert">
    The alarm has no `OKActions` pointing at the SNS topic. Add it with `put-metric-alarm --ok-actions`.
  </Accordion>

  <Accordion title="Nothing arrives in aciona.me" icon="unplug">
    Check, in this order: the Lambda's subscription to the topic is confirmed; the Lambda logs in CloudWatch Logs; `ACIONA_INGRESS_URL` and `ACIONA_TOKEN` are correct; the Lambda has internet egress (inside a VPC it needs a NAT).
  </Accordion>

  <Accordion title="Wrong service on the incident" icon="server">
    Adjust the alarm dimensions, or create a service in aciona.me matching the name the Lambda derives. See the `extractServiceHint` function in the code.
  </Accordion>

  <Accordion title="Severity always warning" icon="gauge">
    Add `[severity:critical]` to the `AlarmDescription` or include the keyword in the alarm name.
  </Accordion>
</AccordionGroup>
