AWS DevOps Agent にエラーログ・コードスニペット・Backlog 課題を自動送信する Lambda を Claude Codeと対話して実装してみた

AWS DevOps Agent にエラーログ・コードスニペット・Backlog 課題を自動送信する Lambda を Claude Codeと対話して実装してみた

2026.01.13

LINE/アプリ DevOps チームの及川です。

今回、AWS 環境でのエラー発生時に「アラーム内容」、「エラーログ」、「該当ソースコード」、「Backlog でのお問い合わせ内容(該当があれば)」を自動収集し、AWS DevOps Agent へ連携する CloudFormation スタックを実装してみましたのでご紹介します。

はじめに

私たちのチームでは、複数案件における LINE ミニアプリの運用保守業務を担当しています。業務の一つとして、本番環境でエラーが発生した際のお客様への調査・報告があります。

本番エラーは CloudWatch Alarm をトリガーに Slack へ通知され、同時に PagerDuty にも連携されています。エラー調査では、CloudWatch Logs での該当箇所の確認、ソースコードの確認、AWS 公式ドキュメント等の調査、過去の対応履歴(Backlog や Slack スレッド)の確認を行い、整理した上でお客様に Backlog で報告しています。

これらの調査は現在手動で行っており、すぐに解決できるケース(2 時間程度)もあれば、原因特定が困難で大幅に時間がかかるケース(16 時間以上かかる等)もあります。いかに効率よく調査を行い、スムーズにお客様へ報告できるかが課題としてあります。

そこで今回は、エラーログ・コードスニペット・関連課題を自動収集して AWS DevOps Agent に送信する仕組みを、Claude Code と共に CloudFormation スタックとして実装しました。

AWS DevOps Agent とは

AWS DevOps Agent は、インシデントの解決と予防を自律的に行う AI エージェントです。(本記事投稿時点では)現在プレビュー版として提供されています。

https://aws.amazon.com/jp/devops-agent/

この記事で紹介すること

AWS DevOps Agent の活用

エラー発生時に、AWS DevOps Agent へ以下の情報を自動連携します。

  • CloudWatch Logs からのエラー発生時の該当時間帯のエラーログ
  • GitHub からエラーログと関連するコードスニペット
  • Backlog からの関連課題(例: エラー発生時刻から 48 時間以内)

CloudFormation スタックによる適用

この方式を選択した理由は以下の通りです。

  • 既存プロジェクト(AWS CDK)に直接組み込まず、拡張機能として後付けで設定可能
  • 不要になった場合の削除や、新しいバージョンへの入れ替えが柔軟に行える
  • SSM Parameter Store に事前設定した値を参照するような仕組みを取り入れ、デプロイ時の手間を軽減しつつ、運用中も「Backlog 検索期間」などを柔軟に調整可能にするため

Claude Code との共創

今回の仕組み実装も、Claude Code と対話しながら進めました。

依頼内容

Claude Code には、以下のような依頼を行いました。要点を掻い摘んで示します。

  • CloudWatch Alarm から DevOps Agent へインシデント情報を送信する Lambda を作成すること
  • 設定は SSM Parameter Store で管理し、デプロイ時のパラメータ入力を最小化すること
  • エラーログ、コードスニペット、関連課題を自動収集すること
  • CloudFormation テンプレートとセットアップガイドを作成すること

ステップバイステップでの改善(例)

最初はぼんやりとしたイメージしかありませんでしたが、Claude Code と壁打ちを重ねる中で、当初の依頼内容以外にも改善点が次々と浮かんできました。1 ステップごとに改善を重ね、セキュリティ観点からテンプレートに機密情報や危険な操作が含まれていないかも入念に確認しました。

改善項目 内容
Description のフォーマット JSON の整形、スタックトレースの行分割
コードスニペット行数 SSM パラメータで前後の取得行数を設定可能に
設定ログ出力 読み込んだ設定値をログ出力(シークレットはマスク)

アーキテクチャ

本記事でご紹介する構成図は以下のようになります。参考情報として共有します。

devops-agent-architecture-v3

処理フロー

  1. CloudWatch Alarm が ALARM 状態になる
  2. SNS Topic に通知が発行される
  3. Lambda がトリガーされる
  4. Lambda が以下の情報を収集:
    • SSM Parameter Store から設定を取得
    • CloudWatch Logs からエラーログを取得
    • GitHub からコードスニペットを取得
    • Backlog から関連課題を検索
  5. 収集した情報を AWS DevOps Agent に送信
  6. AWS DevOps Agent が AI でインシデントを分析

主な機能

CloudWatch Logs 連携

CloudWatch Alarm に関連するエラーログを自動取得します。

メトリクスフィルターからログ取得

// メトリクスフィルターを取得
const command = new DescribeMetricFiltersCommand({
  metricName: message.Trigger?.MetricName,
  metricNamespace: message.Trigger?.Namespace,
});
const response = await logsClient.send(command);

アラームのメトリクス名から対応するメトリクスフィルターを特定し、そのフィルターパターンでログを検索します。

Lambda Powertools 形式の検出

AWS Lambda Powertools で構造化ログを出力している場合、function_request_id を使って同一リクエストのログをまとめて取得します。

function isLambdaPowertools(logMessage) {
  try {
    const json = JSON.parse(logMessage);
    return Object.prototype.hasOwnProperty.call(json, "function_request_id");
  } catch {
    return false;
  }
}

GitHub コードスニペット取得

エラーログのスタックトレースから、関連するコードを自動取得します。

Lambda 関数が GitHub リポジトリからソースコードを取得するため、事前に GitHub Personal Access Token を作成してください。トークンには Permissions で「Contents」に Read-only 権限を付与する必要があります。

スクリーンショット_2026-01-06_22_57_09

スタックトレースからファイルパス抽出

const patterns = [
  /at\s+(?:\S+\s+\()?(?:\/var\/task\/)?([^:)]+):(\d+)/g, // Node.js スタックトレース
  /File\s+"([^"]+)",\s+line\s+(\d+)/g, // Python スタックトレース
  /([a-zA-Z0-9_/.-]+\.[tj]sx?):(\d+)/g, // 汎用パターン
];

複数のパターンでファイルパスと行番号を抽出し、最大 3 ファイルのコードスニペットを取得します。

コードスニペットの表示例

=== src/handler/api/controller/todo-controller.ts:42 ===
  34 | export class TodoController {
  35 |   constructor(
  36 |     private readonly todoUseCase: TodoUseCase,
  37 |   ) {}
  38 |
  39 |   async getTodos(req: Request, res: Response) {
  40 |     const userId = req.user?.id;
  41 |     if (!userId) {
> 42 |       throw new Error("User ID is required");
  43 |     }
  44 |     const todos = await this.todoUseCase.getTodos(userId);
  45 |     res.send(todos);
  46 |   }
  47 | }

エラー行は > マーカーで強調表示されます。前後の取得行数は SSM パラメータで設定可能です(デフォルト: 前 8 行、後 7 行)。

Webhook 連携

AWS DevOps Agent にインシデント情報を送信することで、調査を開始させることができます。

AWS DevOps Agent との連携は、以下の流れで行います。

  1. AWS DevOps Agent コンソールで Webhook URL と Secret を取得
  2. イベントデータを JSON で構築
  3. HMAC-SHA256 署名を生成
  4. Webhook URL に POST リクエスト

本記事では、基本的な Webhook 設定手順は省略し、Lambda からの連携実装に焦点を当てます。Webhook の設定手順については、以下の弊社ブログ記事でも詳しく解説されていますので適宜ご参照いただければ幸いです。

https://dev.classmethod.jp/articles/aws-devops-agent-preview-pagerduty-webhook-integration/#Webhook%25E9%2580%25A3%25E6%2590%25BA%25E3%2581%25AE%25E8%25A8%25AD%25E5%25AE%259A

Backlog 課題検索

アラーム発生時刻の前後で作成された課題を検索し、関連する顧客問い合わせがないか確認します。

const params = new URLSearchParams();
params.append("projectId[]", config.backlogProjectId);
params.append("statusId[]", "1"); // 未対応
params.append("statusId[]", "2"); // 処理中
params.append("statusId[]", "3"); // 処理済み
params.append("createdSince", formatDate(createdSince));
params.append("createdUntil", formatDate(createdUntil));

また、Backlogでもエラー発生時間帯の課題を検索する際に、APIキーが必要ですので、事前に取得(保管)して、後述の SSM Parameter Store に設定してください。

スクリーンショット_2026-01-12_19_35_48

検索期間等は SSM パラメータで設定可能です(デフォルト: 24 時間)。

SSM Parameter Store による設定管理

本記事でご紹介する機能(仕組み)に関する設定情報は SSM Parameter Store で管理しています。

設定の分離

種別 SSM Type
機密情報 SecureString GitHub Token, API Key
設定情報 String Webhook URL, リポジトリ名

機密情報は KMS で暗号化され、CloudFormation テンプレートには含まれません。

https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-paramstore-access.html

パラメータ一覧(設定例)

CloudFormation スタックをデプロイする前に、以下のパラメータを SSM Parameter Store に登録してください。

Note: {prefix} のデフォルト値は /incident-handler です。
例: /incident-handler/github-token, /incident-handler/config/webhook-url

パラメータパス 種別 必須 デフォルト
{prefix}/github-token SecureString -
{prefix}/devops-agent-secret SecureString -
{prefix}/backlog-api-key SecureString -
{prefix}/config/sns-topic-arn String -
{prefix}/config/webhook-url String -
{prefix}/config/github-owner String -
{prefix}/config/github-repo String -
{prefix}/config/github-branch String - main
{prefix}/config/github-code-lines-before String - 8
{prefix}/config/github-code-lines-after String - 7
{prefix}/config/backlog-space-id String -
{prefix}/config/backlog-domain String - backlog.jp
{prefix}/config/backlog-project-id String -
{prefix}/config/backlog-search-hours String - 24

CloudFormationスタック

以上まででご紹介した機能内容をまとめた今回実装した内容について、CloudFormation スタックに整理しましたので以下に共有します。

devops-agent-incident-handler-cloudformation.yaml
AWSTemplateFormatVersion: "2010-09-09"
Description: "DevOps Agent Incident Handler - Recommended Stack Name: DevOpsAgentLambdaStack"

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "SSM Parameter Store Settings (Recommended Stack Name: DevOpsAgentLambdaStack)"
        Parameters:
          - SsmParameterPrefix
    ParameterLabels:
      SsmParameterPrefix:
        default: SSM Parameter Prefix

Parameters:
  SsmParameterPrefix:
    Type: String
    Default: /incident-handler
    Description: |
      SSM Parameter Store prefix. Create ALL parameters before deployment:
      [Required - SecureString]
        {prefix}/github-token, {prefix}/devops-agent-secret, {prefix}/backlog-api-key
      [Required - String]
        {prefix}/config/sns-topic-arn, {prefix}/config/webhook-url
        {prefix}/config/github-owner, {prefix}/config/github-repo
        {prefix}/config/backlog-space-id, {prefix}/config/backlog-project-id
      [Optional - String]
        {prefix}/config/github-branch (default: main)
        {prefix}/config/github-code-lines-before (default: 8)
        {prefix}/config/github-code-lines-after (default: 7)
        {prefix}/config/backlog-domain (default: backlog.jp)
        {prefix}/config/backlog-search-hours (default: 24)
    AllowedPattern: "^/[a-zA-Z0-9/_-]+$"
    ConstraintDescription: Must start with / and contain only alphanumeric, /, _, -

Resources:
  # DevOps Agent Incident Handler IAM Role
  DevOpsAgentIncidentHandlerRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: devops-agent-incident-handler-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: CloudWatchLogsPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:DescribeMetricFilters
                  - logs:FilterLogEvents
                Resource: "*"
        - PolicyName: SSMParameterStorePolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - ssm:GetParameter
                  - ssm:GetParameters
                Resource:
                  - !Sub "arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter${SsmParameterPrefix}/*"

  # DevOps Agent Incident Handler Lambda Function
  DevOpsAgentIncidentHandlerFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: devops-agent-incident-handler
      Description: CloudWatch Alarm to DevOps Agent (with code snippets and Backlog issues) - SSM version
      Runtime: nodejs22.x
      Handler: index.handler
      Role: !GetAtt DevOpsAgentIncidentHandlerRole.Arn
      Timeout: 120
      MemorySize: 256
      Environment:
        Variables:
          SSM_PARAMETER_PREFIX: !Ref SsmParameterPrefix
          AWS_ACCOUNT_ID: !Ref AWS::AccountId
      Code:
        ZipFile: |
          const { createHmac } = require("node:crypto");
          const https = require("node:https");
          const { URL } = require("node:url");
          const { CloudWatchLogsClient, DescribeMetricFiltersCommand, FilterLogEventsCommand } = require("@aws-sdk/client-cloudwatch-logs");
          const { SSMClient, GetParametersCommand } = require("@aws-sdk/client-ssm");

          const logsClient = new CloudWatchLogsClient({});
          const ssmClient = new SSMClient({});

          // Cache for SSM parameters (to avoid repeated API calls)
          let cachedConfig = null;

          async function getConfig() {
            if (cachedConfig) {
              return cachedConfig;
            }

            const prefix = process.env.SSM_PARAMETER_PREFIX || "/incident-handler";

            // Define parameter names
            const secretParams = [
              `${prefix}/github-token`,
              `${prefix}/devops-agent-secret`,
              `${prefix}/backlog-api-key`,
            ];

            const configParams = [
              `${prefix}/config/webhook-url`,
              `${prefix}/config/github-owner`,
              `${prefix}/config/github-repo`,
              `${prefix}/config/github-branch`,
              `${prefix}/config/github-code-lines-before`,
              `${prefix}/config/github-code-lines-after`,
              `${prefix}/config/backlog-space-id`,
              `${prefix}/config/backlog-domain`,
              `${prefix}/config/backlog-project-id`,
              `${prefix}/config/backlog-search-hours`,
            ];

            // Fetch secrets (with decryption)
            const secretsResult = await ssmClient.send(new GetParametersCommand({
              Names: secretParams,
              WithDecryption: true,
            }));

            // Fetch config (no decryption needed for String type)
            const configResult = await ssmClient.send(new GetParametersCommand({
              Names: configParams,
              WithDecryption: false,
            }));

            const getValue = (params, suffix) => {
              const param = params.find(p => p.Name?.endsWith(suffix));
              return param?.Value || "";
            };

            const secretParamsResult = secretsResult.Parameters || [];
            const configParamsResult = configResult.Parameters || [];

            cachedConfig = {
              // Secrets
              gitHubToken: getValue(secretParamsResult, "/github-token"),
              eventAiSecret: getValue(secretParamsResult, "/devops-agent-secret"),
              backlogApiKey: getValue(secretParamsResult, "/backlog-api-key"),
              // Config
              eventAiWebhookUrl: getValue(configParamsResult, "/config/webhook-url"),
              gitHubOwner: getValue(configParamsResult, "/config/github-owner"),
              gitHubRepo: getValue(configParamsResult, "/config/github-repo"),
              gitHubDefaultBranch: getValue(configParamsResult, "/config/github-branch") || "main",
              gitHubCodeLinesBefore: parseInt(getValue(configParamsResult, "/config/github-code-lines-before") || "8", 10),
              gitHubCodeLinesAfter: parseInt(getValue(configParamsResult, "/config/github-code-lines-after") || "7", 10),
              backlogSpaceId: getValue(configParamsResult, "/config/backlog-space-id"),
              backlogDomain: getValue(configParamsResult, "/config/backlog-domain") || "backlog.jp",
              backlogProjectId: getValue(configParamsResult, "/config/backlog-project-id"),
              backlogSearchPeriodHours: parseInt(getValue(configParamsResult, "/config/backlog-search-hours") || "24", 10),
              // Static
              awsAccountId: process.env.AWS_ACCOUNT_ID || "unknown",
              awsRegion: process.env.AWS_REGION || "ap-northeast-1",
            };

            // Log missing required parameters
            const missing = [];
            if (!cachedConfig.eventAiWebhookUrl) missing.push("webhook-url");
            if (!cachedConfig.eventAiSecret) missing.push("devops-agent-secret");
            if (!cachedConfig.gitHubOwner) missing.push("github-owner");
            if (!cachedConfig.gitHubRepo) missing.push("github-repo");
            if (!cachedConfig.gitHubToken) missing.push("github-token");
            if (!cachedConfig.backlogSpaceId) missing.push("backlog-space-id");
            if (!cachedConfig.backlogProjectId) missing.push("backlog-project-id");
            if (!cachedConfig.backlogApiKey) missing.push("backlog-api-key");

            if (missing.length > 0) {
              console.warn("Missing SSM parameters:", missing.join(", "));
            }

            // Log loaded configuration (with secrets masked)
            const maskSecret = (value) => value ? "***" : "(not set)";
            console.log("Loaded SSM configuration:", JSON.stringify({
              // Secrets (masked)
              gitHubToken: maskSecret(cachedConfig.gitHubToken),
              eventAiSecret: maskSecret(cachedConfig.eventAiSecret),
              backlogApiKey: maskSecret(cachedConfig.backlogApiKey),
              // Config (visible)
              eventAiWebhookUrl: cachedConfig.eventAiWebhookUrl || "(not set)",
              gitHubOwner: cachedConfig.gitHubOwner || "(not set)",
              gitHubRepo: cachedConfig.gitHubRepo || "(not set)",
              gitHubDefaultBranch: cachedConfig.gitHubDefaultBranch,
              gitHubCodeLinesBefore: cachedConfig.gitHubCodeLinesBefore,
              gitHubCodeLinesAfter: cachedConfig.gitHubCodeLinesAfter,
              backlogSpaceId: cachedConfig.backlogSpaceId || "(not set)",
              backlogDomain: cachedConfig.backlogDomain,
              backlogProjectId: cachedConfig.backlogProjectId || "(not set)",
              backlogSearchPeriodHours: cachedConfig.backlogSearchPeriodHours,
              // Static
              awsAccountId: cachedConfig.awsAccountId,
              awsRegion: cachedConfig.awsRegion,
            }, null, 2));

            return cachedConfig;
          }

          // Settings
          const OUTPUT_LIMIT = 20;
          const TIME_FROM_MIN = 3;
          const TIME_ERROR_AROUND = 30;
          const BACKLOG_MAX_ISSUES = 5;

          exports.handler = async (event) => {
            console.log("Received event:", JSON.stringify(event, null, 2));

            // Get config from SSM Parameter Store
            const config = await getConfig();

            for (const record of event.Records) {
              try {
                const message = JSON.parse(record.Sns.Message);
                console.log("Parsed message:", JSON.stringify(message, null, 2));

                // Skip OK state notifications (auto-resolve disabled)
                if (message.NewStateValue === "OK") {
                  console.log("Alarm recovered to OK state, skipping notification (auto-resolve disabled)");
                  continue;
                }

                // Get metric filters and error logs
                const filter = await getMetricFilters(message);
                let errorLogs = [];

                if (filter) {
                  const triggerLogs = await getTriggerLogEvents(message, filter);
                  if (triggerLogs.length > 0) {
                    const triggerLogMessage = triggerLogs[0].message || "";
                    if (isLambdaPowertools(triggerLogMessage)) {
                      console.log("Lambda Powertools format detected, fetching context logs");
                      errorLogs = await getContextLogs(triggerLogMessage, filter);
                    } else {
                      errorLogs = triggerLogs;
                    }
                  }
                }

                // Get code snippets from GitHub
                let codeSnippets = "";
                if (config.gitHubToken && config.gitHubOwner && config.gitHubRepo && errorLogs.length > 0) {
                  try {
                    codeSnippets = await getCodeSnippets(errorLogs, config);
                    console.log("Code snippets retrieved:", codeSnippets.length > 0 ? "Yes" : "No");
                  } catch (error) {
                    console.log("GitHub code fetch failed:", error.message);
                  }
                }

                // Search related Backlog issues
                let backlogIssues = [];
                if (config.backlogApiKey && config.backlogSpaceId && config.backlogProjectId) {
                  try {
                    backlogIssues = await searchBacklogIssues(message, config);
                    console.log("Backlog issues found:", backlogIssues.length);
                  } catch (error) {
                    console.log("Backlog search failed:", error.message);
                  }
                }

                // Send to DevOps Agent
                const devOpsAgentResult = await sendToDevOpsAgentWithRetry(message, errorLogs, codeSnippets, backlogIssues, config);
                console.log("DevOps Agent result:", devOpsAgentResult);

                if (devOpsAgentResult.statusCode < 200 || devOpsAgentResult.statusCode >= 300) {
                  throw new Error(`DevOps Agent failed: ${devOpsAgentResult.statusCode} - ${devOpsAgentResult.body}`);
                }
              } catch (error) {
                console.error("Error processing record:", error);
                throw error;
              }
            }

            return { statusCode: 200, body: "OK" };
          };

          // ========== CloudWatch Logs Functions ==========

          async function getMetricFilters(message) {
            try {
              const command = new DescribeMetricFiltersCommand({
                metricName: message.Trigger?.MetricName,
                metricNamespace: message.Trigger?.Namespace,
              });
              const response = await logsClient.send(command);

              if (!response.metricFilters?.[0]) {
                console.log("MetricFilters not found");
                return null;
              }
              return response.metricFilters[0];
            } catch (error) {
              console.warn("Failed to get metric filters:", error.message);
              return null;
            }
          }

          async function getTriggerLogEvents(message, filter) {
            try {
              const stateChangeTime = message.StateChangeTime
                ? new Date(message.StateChangeTime)
                : new Date();

              const endTime = stateChangeTime.getTime() + 60000;
              const startTime = endTime - (TIME_FROM_MIN * 60 * 1000);

              const command = new FilterLogEventsCommand({
                logGroupName: filter.logGroupName,
                filterPattern: filter.filterPattern,
                startTime,
                endTime,
                limit: OUTPUT_LIMIT,
              });

              const response = await logsClient.send(command);
              return (response.events || []).map(e => ({
                timestamp: new Date(e.timestamp).toISOString(),
                message: e.message || "",
              }));
            } catch (error) {
              console.warn("Failed to fetch trigger logs:", error.message);
              return [];
            }
          }

          function isLambdaPowertools(logMessage) {
            try {
              const json = JSON.parse(logMessage);
              return Object.prototype.hasOwnProperty.call(json, "function_request_id");
            } catch {
              return false;
            }
          }

          async function getContextLogs(triggerLogMessage, filter) {
            try {
              const logJson = JSON.parse(triggerLogMessage);
              const errorTime = new Date(logJson.timestamp).getTime();

              const command = new FilterLogEventsCommand({
                logGroupName: filter.logGroupName,
                filterPattern: `{$.function_request_id = "${logJson.function_request_id}"}`,
                startTime: errorTime - (TIME_ERROR_AROUND * 1000),
                endTime: errorTime + (TIME_ERROR_AROUND * 1000),
                limit: OUTPUT_LIMIT,
              });

              const response = await logsClient.send(command);
              return (response.events || []).map(e => ({
                timestamp: new Date(e.timestamp).toISOString(),
                message: e.message || "",
              }));
            } catch (error) {
              console.warn("Failed to fetch context logs:", error.message);
              return [];
            }
          }

          function formatErrorLogs(errorLogs) {
            if (errorLogs.length === 0) return "";
            return errorLogs.map((log, i) => {
              let formattedMessage = log.message;

              // JSON形式のメッセージを整形
              try {
                const parsed = JSON.parse(log.message);
                // スタックトレースを行ごとに分割
                if (parsed.error?.stack) {
                  parsed.error.stack = parsed.error.stack
                    .split("\\n")
                    .map(line => line.trim())
                    .join("\n      ");
                }
                formattedMessage = JSON.stringify(parsed, null, 2);
              } catch {
                // JSON でない場合はそのまま
              }

              return `[${i + 1}] ${log.timestamp}\n${formattedMessage}`;
            }).join("\n\n");
          }

          // ========== GitHub Functions ==========

          function extractFilePathsFromLogs(errorLogs) {
            const filePaths = new Set();
            const patterns = [
              /at\s+(?:\S+\s+\()?(?:\/var\/task\/)?([^:)]+):(\d+)/g,
              /File\s+"([^"]+)",\s+line\s+(\d+)/g,
              /([a-zA-Z0-9_/.-]+\.[tj]sx?):(\d+)/g,
            ];

            for (const log of errorLogs) {
              const message = typeof log === "string" ? log : log.message || "";
              for (const pattern of patterns) {
                pattern.lastIndex = 0;
                let match;
                while ((match = pattern.exec(message)) !== null) {
                  let filePath = match[1];
                  if (filePath.includes("node_modules") || filePath.startsWith("node:")) continue;
                  filePath = filePath.replace(/^\/var\/task\//, "").replace(/^\/+/, "");
                  const lineNum = parseInt(match[2], 10);
                  if (filePath && lineNum > 0) {
                    filePaths.add(JSON.stringify({ path: filePath, line: lineNum }));
                  }
                }
              }
            }

            return [...filePaths].map(JSON.parse).slice(0, 3);
          }

          async function fetchGitHubCode(filePath, config) {
            const encodedPath = filePath.split("/").map(segment => encodeURIComponent(segment)).join("/");
            return new Promise((resolve, reject) => {
              const options = {
                hostname: "api.github.com",
                path: `/repos/${config.gitHubOwner}/${config.gitHubRepo}/contents/${encodedPath}?ref=${config.gitHubDefaultBranch}`,
                method: "GET",
                headers: {
                  "Authorization": `Bearer ${config.gitHubToken}`,
                  "Accept": "application/vnd.github.raw+json",
                  "X-GitHub-Api-Version": "2022-11-28",
                  "User-Agent": "DevOpsAgentIncidentHandler/1.0",
                },
              };

              const req = https.request(options, (res) => {
                let data = "";
                res.on("data", chunk => data += chunk);
                res.on("end", () => {
                  if (res.statusCode === 200) {
                    resolve(data);
                  } else {
                    reject(new Error(`GitHub API error: ${res.statusCode}`));
                  }
                });
              });

              req.on("error", reject);
              req.setTimeout(10000, () => {
                req.destroy();
                reject(new Error("GitHub request timeout"));
              });
              req.end();
            });
          }

          async function getCodeSnippets(errorLogs, config) {
            const filePaths = extractFilePathsFromLogs(errorLogs);
            if (filePaths.length === 0) {
              console.log("No file paths extracted from logs");
              return "";
            }

            console.log("Extracted file paths:", JSON.stringify(filePaths));
            const snippets = [];

            for (const { path, line } of filePaths) {
              try {
                const content = await fetchGitHubCode(path, config);
                const lines = content.split("\n");
                const start = Math.max(0, line - config.gitHubCodeLinesBefore);
                const end = Math.min(lines.length, line + config.gitHubCodeLinesAfter);
                const snippet = lines.slice(start, end)
                  .map((l, i) => {
                    const lineNum = start + i + 1;
                    const marker = lineNum === line ? ">" : " ";
                    return `${String(lineNum).padStart(4)}${marker}| ${l}`;
                  })
                  .join("\n");
                const snippetBlock = `=== ${path}:${line} ===\n${snippet}`;
                console.log(`Code snippet from GitHub:\n${snippetBlock}`);
                snippets.push(snippetBlock);
              } catch (error) {
                console.log(`Failed to fetch ${path}: ${error.message}`);
              }
            }

            return snippets.join("\n\n");
          }

          // ========== Backlog Functions ==========

          async function searchBacklogIssues(alarmMessage, config) {
            const alarmTime = alarmMessage.StateChangeTime
              ? new Date(alarmMessage.StateChangeTime)
              : new Date();

            const createdSince = new Date(alarmTime.getTime() - (config.backlogSearchPeriodHours * 60 * 60 * 1000));
            const createdUntil = new Date(alarmTime.getTime() + (60 * 60 * 1000));

            const formatDate = (d) => d.toISOString().split("T")[0];

            const params = new URLSearchParams();
            params.append("projectId[]", config.backlogProjectId);
            params.append("statusId[]", "1");
            params.append("statusId[]", "2");
            params.append("statusId[]", "3");
            params.append("createdSince", formatDate(createdSince));
            params.append("createdUntil", formatDate(createdUntil));
            params.append("sort", "created");
            params.append("order", "desc");
            params.append("count", String(BACKLOG_MAX_ISSUES));

            const backlogHost = `${config.backlogSpaceId}.${config.backlogDomain}`;
            console.log("Backlog API host:", backlogHost);

            return new Promise((resolve, reject) => {
              const options = {
                hostname: backlogHost,
                path: `/api/v2/issues?apiKey=${config.backlogApiKey}&${params.toString()}`,
                method: "GET",
                headers: {
                  "Accept": "application/json",
                },
              };

              const req = https.request(options, (res) => {
                let data = "";
                res.on("data", chunk => data += chunk);
                res.on("end", () => {
                  if (res.statusCode === 200) {
                    try {
                      const issues = JSON.parse(data);
                      Promise.all(issues.slice(0, 3).map(issue => fetchIssueWithComments(issue, config)))
                        .then(resolve)
                        .catch(() => resolve(issues.map(formatIssue)));
                    } catch (e) {
                      reject(new Error(`Backlog parse error: ${e.message}`));
                    }
                  } else {
                    reject(new Error(`Backlog API error: ${res.statusCode} - ${data.substring(0, 200)}`));
                  }
                });
              });

              req.on("error", reject);
              req.setTimeout(15000, () => {
                req.destroy();
                reject(new Error("Backlog request timeout"));
              });
              req.end();
            });
          }

          async function fetchIssueWithComments(issue, config) {
            try {
              const comments = await fetchIssueComments(issue.issueKey, config);
              return {
                ...formatIssue(issue),
                comments: comments.slice(0, 3),
              };
            } catch {
              return formatIssue(issue);
            }
          }

          async function fetchIssueComments(issueKey, config) {
            const backlogHost = `${config.backlogSpaceId}.${config.backlogDomain}`;
            return new Promise((resolve, reject) => {
              const options = {
                hostname: backlogHost,
                path: `/api/v2/issues/${issueKey}/comments?apiKey=${config.backlogApiKey}&count=5&order=desc`,
                method: "GET",
                headers: { "Accept": "application/json" },
              };

              const req = https.request(options, (res) => {
                let data = "";
                res.on("data", chunk => data += chunk);
                res.on("end", () => {
                  if (res.statusCode === 200) {
                    try {
                      const comments = JSON.parse(data);
                      resolve(comments.map(c => ({
                        content: c.content || "",
                        createdUser: c.createdUser?.name || "Unknown",
                        created: c.created || "",
                      })));
                    } catch {
                      resolve([]);
                    }
                  } else {
                    resolve([]);
                  }
                });
              });

              req.on("error", () => resolve([]));
              req.setTimeout(10000, () => {
                req.destroy();
                resolve([]);
              });
              req.end();
            });
          }

          function formatIssue(issue) {
            return {
              issueKey: issue.issueKey || "",
              summary: issue.summary || "",
              description: issue.description || "",
              status: issue.status?.name || "",
              priority: issue.priority?.name || "",
              createdUser: issue.createdUser?.name || "",
              created: issue.created || "",
              updated: issue.updated || "",
            };
          }

          function formatBacklogIssues(issues, config) {
            if (issues.length === 0) return "";

            return issues.map((issue, i) => {
              const lines = [
                `[${i + 1}] ${issue.issueKey}: ${issue.summary}`,
                `    Status: ${issue.status} | Priority: ${issue.priority}`,
                `    Created: ${issue.created} by ${issue.createdUser}`,
              ];

              if (issue.description) {
                const desc = issue.description.length > 300
                  ? issue.description.substring(0, 300) + "..."
                  : issue.description;
                lines.push(`    Description: ${desc.replace(/\n/g, " ")}`);
              }

              if (issue.comments && issue.comments.length > 0) {
                lines.push(`    Recent Comments:`);
                issue.comments.forEach((c, j) => {
                  const content = c.content.length > 150
                    ? c.content.substring(0, 150) + "..."
                    : c.content;
                  lines.push(`      [${j + 1}] ${c.createdUser} (${c.created}): ${content.replace(/\n/g, " ")}`);
                });
              }

              return lines.join("\n");
            }).join("\n\n");
          }

          // ========== DevOps Agent Functions ==========

          async function sendToDevOpsAgentWithRetry(alarmMessage, errorLogs, codeSnippets, backlogIssues, config) {
            if (alarmMessage.NewStateValue === "OK") {
              console.log("Skipping DevOps Agent for OK state");
              return { statusCode: 200, body: "Skipped for OK state" };
            }

            const dimensions = alarmMessage.Trigger?.Dimensions || [];
            const functionDim = dimensions.find(d => d.name === "FunctionName");

            const descriptionLines = [];
            descriptionLines.push("Please respond in Japanese.");
            descriptionLines.push("");
            descriptionLines.push(`Status: ${alarmMessage.NewStateValue || "ALARM"}`);
            descriptionLines.push("");

            if (alarmMessage.NewStateReason) {
              descriptionLines.push(`Reason: ${alarmMessage.NewStateReason}`);
              descriptionLines.push("");
            }

            if (functionDim?.value) {
              descriptionLines.push(`Lambda Function: ${functionDim.value}`);
              descriptionLines.push("");
            }

            if (alarmMessage.Trigger?.Namespace && alarmMessage.Trigger?.MetricName) {
              descriptionLines.push(`Metric: ${alarmMessage.Trigger.Namespace}/${alarmMessage.Trigger.MetricName}`);
              descriptionLines.push("");
            }

            if (alarmMessage.AlarmName) {
              descriptionLines.push(`Alarm Name: ${alarmMessage.AlarmName}`);
              descriptionLines.push("");
            }

            if (alarmMessage.Region) {
              descriptionLines.push(`Region: ${alarmMessage.Region}`);
              descriptionLines.push("");
            }

            if (alarmMessage.AWSAccountId) {
              descriptionLines.push(`Account ID: ${alarmMessage.AWSAccountId}`);
              descriptionLines.push("");
            }

            if (alarmMessage.StateChangeTime) {
              descriptionLines.push(`Incident Time: ${alarmMessage.StateChangeTime}`);
              descriptionLines.push("");
            }

            if (alarmMessage.AlarmArn) {
              const consoleUrl = `https://${alarmMessage.Region}.console.aws.amazon.com/cloudwatch/home?region=${alarmMessage.Region}#alarmsV2:alarm/${encodeURIComponent(alarmMessage.AlarmName)}`;
              descriptionLines.push(`CloudWatch Console: ${consoleUrl}`);
              descriptionLines.push("");
            }

            if (errorLogs.length > 0) {
              descriptionLines.push("");
              descriptionLines.push("=== Error Logs (CloudWatch Logs) ===");
              descriptionLines.push("");
              descriptionLines.push(formatErrorLogs(errorLogs));
            }

            if (codeSnippets) {
              descriptionLines.push("");
              descriptionLines.push("=== Related Code (GitHub) ===");
              descriptionLines.push("");
              descriptionLines.push(codeSnippets);
            }

            if (backlogIssues && backlogIssues.length > 0) {
              descriptionLines.push("");
              descriptionLines.push(`=== Related Customer Inquiries (Backlog - within ${config.backlogSearchPeriodHours}h) ===`);
              descriptionLines.push("");
              descriptionLines.push(formatBacklogIssues(backlogIssues, config));
            }

            const payload = {
              eventType: "incident",
              incidentId: `cloudwatch:${alarmMessage.AlarmArn || Date.now()}`,
              action: "created",
              priority: "HIGH",
              title: `[${alarmMessage.NewStateValue}] ${alarmMessage.AlarmName}: ${alarmMessage.AlarmDescription || "CloudWatch Alarm triggered"}`,
              description: descriptionLines.join("\n"),
              timestamp: alarmMessage.StateChangeTime || new Date().toISOString(),
              service: "cloudwatch",
              affectedResources: alarmMessage.AlarmArn ? [alarmMessage.AlarmArn] : [],
            };

            console.log("Sending to DevOps Agent - Code snippets:", codeSnippets ? "Yes" : "No", "- Backlog issues:", backlogIssues?.length || 0);
            console.log("DevOps Agent payload summary:", JSON.stringify({
              eventType: payload.eventType,
              incidentId: payload.incidentId,
              priority: payload.priority,
              title: payload.title,
              descriptionLength: payload.description.length,
              hasCodeSnippets: !!codeSnippets,
              backlogIssuesCount: backlogIssues?.length || 0,
            }, null, 2));

            return sendToEventAiWithRetry(payload, config);
          }

          async function sendToEventAiWithRetry(payload, config) {
            const maxRetries = 3;
            const retryDelayMs = 1000;
            let lastError;

            for (let attempt = 1; attempt <= maxRetries; attempt++) {
              try {
                const result = await sendToEventAi(payload, config);

                if (result.statusCode >= 200 && result.statusCode < 300) {
                  return result;
                }

                if (result.statusCode >= 400 && result.statusCode < 500) {
                  console.error("Client error, not retrying:", result.statusCode);
                  return result;
                }

                lastError = new Error(`HTTP ${result.statusCode}: ${result.body}`);
              } catch (error) {
                lastError = error;
                console.error(`Request failed (attempt ${attempt}):`, error.message);
              }

              if (attempt < maxRetries) {
                const delay = retryDelayMs * attempt;
                await new Promise(resolve => setTimeout(resolve, delay));
              }
            }

            throw lastError || new Error("Unknown error");
          }

          async function sendToEventAi(payload, config) {
            const payloadString = JSON.stringify(payload);
            const tsHeader = new Date().toISOString();
            const hmac = createHmac("sha256", config.eventAiSecret);
            hmac.update(`${tsHeader}:${payloadString}`, "utf8");
            const signature = hmac.digest("base64");

            const url = new URL(config.eventAiWebhookUrl);
            const options = {
              hostname: url.hostname,
              path: url.pathname + url.search,
              method: "POST",
              port: url.port || 443,
              headers: {
                "Content-Type": "application/json",
                "x-amzn-event-timestamp": tsHeader,
                "x-amzn-event-signature": signature,
                "Content-Length": Buffer.byteLength(payloadString),
              },
            };

            return new Promise((resolve, reject) => {
              const req = https.request(options, (res) => {
                let data = "";
                res.on("data", chunk => data += chunk);
                res.on("end", () => {
                  console.log(`DevOps Agent response: ${res.statusCode} - ${data}`);
                  resolve({ statusCode: res.statusCode || 200, body: data });
                });
              });
              req.on("error", reject);
              req.write(payloadString);
              req.end();
            });
          }

  # SNS -> Lambda Permission
  DevOpsAgentIncidentHandlerPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref DevOpsAgentIncidentHandlerFunction
      Action: lambda:InvokeFunction
      Principal: sns.amazonaws.com
      SourceArn: !Sub "{{resolve:ssm:${SsmParameterPrefix}/config/sns-topic-arn}}"

  # SNS Subscription
  DevOpsAgentIncidentHandlerSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Sub "{{resolve:ssm:${SsmParameterPrefix}/config/sns-topic-arn}}"
      Protocol: lambda
      Endpoint: !GetAtt DevOpsAgentIncidentHandlerFunction.Arn

  # Log Group
  DevOpsAgentIncidentHandlerLogGroup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: /aws/lambda/devops-agent-incident-handler
      RetentionInDays: 30

Outputs:
  DevOpsAgentIncidentHandlerArn:
    Description: DevOps Agent Incident Handler Lambda ARN
    Value: !GetAtt DevOpsAgentIncidentHandlerFunction.Arn

  DevOpsAgentIncidentHandlerLogGroup:
    Description: CloudWatch Logs log group name
    Value: !Ref DevOpsAgentIncidentHandlerLogGroup

  SsmParameterPaths:
    Description: Required SSM Parameter Store paths (must be created before deployment)
    Value: !Sub |
      [Secrets - SecureString]
      ${SsmParameterPrefix}/github-token
      ${SsmParameterPrefix}/devops-agent-secret
      ${SsmParameterPrefix}/backlog-api-key

      [Config - String (Required)]
      ${SsmParameterPrefix}/config/sns-topic-arn
      ${SsmParameterPrefix}/config/webhook-url
      ${SsmParameterPrefix}/config/github-owner
      ${SsmParameterPrefix}/config/github-repo
      ${SsmParameterPrefix}/config/backlog-space-id
      ${SsmParameterPrefix}/config/backlog-project-id

      [Config - String (Optional)]
      ${SsmParameterPrefix}/config/github-branch (default: main)
      ${SsmParameterPrefix}/config/backlog-domain (default: backlog.jp)
      ${SsmParameterPrefix}/config/backlog-search-hours (default: 24)

  IntegrationSummary:
    Description: Integration summary
    Value: "CloudWatch Alarm -> DevOps Agent (error logs + GitHub code + Backlog issues) - All config from SSM Parameter Store"

Quick Create リンク

CloudFormation の Quick Create 機能を使って、ワンクリックでスタックをデプロイすることも可能です。

前述した SSM パラメータを事前登録した後に、以下の Quick Create URL からスタックを作成してください。

https://ap-northeast-1.console.aws.amazon.com/cloudformation/home?region=ap-northeast-1#/stacks/quickcreate?templateURL=https://cfn-templates-devops-agent-incident-handler.s3.ap-northeast-1.amazonaws.com/devops-agent-incident-handler.yaml&stackName=DevOpsAgentLambdaStack&param_SsmParameterPrefix=/incident-handler

動作確認

実際にデプロイして動作確認をしましたので、かいつまんで参考情報として共有します。

エラー発生

適当にエラーになるように、アラーム状態にします。

スクリーンショット_2026-01-06_23_57_36

エラーログに相当する部分のログ

GitHub で管理している対象リポジトリのソースコードから、エラー発生箇所のコードスニペットを取得できています。

スクリーンショット_2026-01-08_0_16_45

AWS DevOps Agent の動作

CloudWatch アラームがアラーム状態になると、SNS トピックに紐づいている今回の CloudFormation スタックでデプロイした Lambda 関数が実行され、必要な情報を取得後に AWS DevOps Agent が起動します。

スクリーンショット_2026-01-07_22_22_30

「User Request」欄に、AWS DevOps Agent が調査に必要な情報を連携しています。また、エラー事象発生時間帯から遡って 48 時間以内に Backlog にお客様から問い合わせがあった場合を想定して、Backlog の課題に含まれるお問い合わせ内容も連携されていることを確認しました。

スクリーンショット_2026-01-08_0_18_15

以下は今回の検証用で Backlog にお問い合わせした内容の例です。

Backlog問い合わせ

その後、AWS DevOps Agent が調査結果を整理してくれていることも確認できました。

スクリーンショット_2026-01-08_0_18_29

スクリーンショット_2026-01-08_0_24_20

まとめ

今回の実装を試してみて、CloudWatch Alarm やエラーログ、GitHub のコードスニペットなどを AWS DevOps Agent と連携することで、インシデント対応の初動を大幅に効率化できる可能性を感じました。

AWS DevOps Agent はこの記事を書いている時点ではまだプレビュー版であり、今後のアップデートにも期待しています。本記事でご紹介した内容もまだまだ改善の余地がありそうですので、引き続きウォッチしながら改善を続けていきたいと思います。

この記事が誰かのお役に立てば幸いです。

参考資料

クラスメソッドオペレーションズ株式会社について

クラスメソッドグループのオペレーション企業です。
運用・保守開発・サポート・情シス・バックオフィスの専門チームが、IT・AI をフル活用した「しくみ」を通じて、お客様の業務代行から課題解決や高付加価値サービスまでを提供するエキスパート集団です。
当社は様々な職種でメンバーを募集しています。
「オペレーション・エクセレンス」と「らしく働く、らしく生きる」を共に実現するカルチャー・しくみ・働き方にご興味がある方は、クラスメソッドオペレーションズ株式会社 コーポレートサイト をぜひご覧ください。※2026 年 1 月 アノテーション㈱から社名変更しました

この記事をシェアする

FacebookHatena blogX

関連記事