
Bot
イベント
該当するコンテンツが見つかりませんでした
マガジン
技術ブログ
― Harness EngineeringとSwitchyardで考えるTokenomics はじめに 1つ前の記事では、NVIDIA研修の事前課題についてご紹介しました。この記事では、NVIDIA の AI Advisors Workshop に参加してきましたので、その続きについてお伝えします。(講師は、マクニカ、NVIDIA の方でした) 研修では、Agentic AI、NVIDIAのAIエコシステム、AI Factory、製造業・金融業でのユースケース、Go-To-Market、PoCの進め方など、技術からビジネスまで幅広いテーマが扱われました。 その中でも、個人的に特に
はじめに おはようございます、IWSです。 Slack やローカル環境から GitHub Actions のワークフローを起動したい、あるいは API 経由で GitHub を操作したい。そんなシーンで避けて通れないのが Personal Access Token(PAT) の存在です。 しかし、PAT には以下のような悩みがつきまといます。 発行した人に紐づくため、退職や異動で使えなくなる 有効期限の更新・再発行など、運用コストが地味に高い 権限スコープが広くなりがちで、セキュリティ的に扱いづらい そこで本記事では、GitHub App を使って短期有効の一時トークンを払い出し、PAT 管理から解放される方法 を紹介します。あわせて、GitHub の GraphQL API を使ってブランチ名をあいまい検索する小ネタも載せています。 そもそも GitHub App とは GitHub App は、GitHub の機能を操作して拡張するために構築できる統合の一種です。ユーザーのサインインやサービス アカウントの作成を必要とせずに、柔軟性を提供し、プロセスの摩擦を軽減できます。 引用: GitHub DocsGitHub アプリの作成について – GitHubドキュメント ざっくり言うと、個人アカウントではなく「アプリ」として GitHub に対して権限を持てる仕組み です。インストールしたリポジトリに対して、必要最小限の権限で、短命な(最大1時間の)アクセストークンを発行できます。 これにより、 個人に紐づかない 権限スコープをリポジトリ単位・操作単位で絞れる トークンは都度発行・短寿命なので漏洩リスクが低い といった嬉しさが得られます。 ゴール 本記事のゴールは次のとおりです。 GitHub App を作成し、対象リポジトリにインストールする Lambda から JWT を生成し、一時アクセストークンを取得する 取得したトークンで repository_dispatch イベントを発火し、GitHub Actions を起動する (おまけ)GraphQL API でブランチ名をあいまい検索する 1. GitHub App を準備する まずは GitHub App を作成します。個人アカウントの Settings → Developer settings → GitHub Apps → New GitHub App から作成します。 主な設定項目は次のとおりです。 GitHub App name : 好きな名前を設定 Homepage URL : 任意の URL(例: https://example.com ) Webhook : 今回は利用しないのでチェックを外す Permissions : 用途に合わせて設定。本記事では Actions : Read and write Contents : Read and write Where can this GitHub App be installed? : Organization のリポジトリへインストールしたいので Any account を選択 作成が完了すると、App の設定画面に遷移します。ここで Client ID を控えておき、ページ下部の Private keys から Generate a private key で .pem ファイルを発行してダウンロードしておきます。この秘密鍵は後ほど JWT の署名に使用します。 続いて Install App のページから、対象の Organization にインストールします。 権限を絞るために Only select repositories を選択し、対象リポジトリを指定してインストールしましょう。 インストール先リポジトリの Settings → GitHub Apps に作成した App が表示されれば準備完了です。 2. JWT を生成する ここから先は AWS Lambda 上での実装例を紹介します(Zapier も検討しましたが、環境変数が扱えず断念しました)。 まずは秘密鍵を使って JWT を生成します。 iss には GitHub App の Client ID を、 exp は最大10分以内に設定します。 import jwt import time def generate_github_jwt(private_key: str) -> str: """GitHub App 用の JWT を生成する""" payload = { 'iat': int(time.time()), 'exp': int(time.time()) + 600, # 最大10分 'iss': '<GitHub App の Client ID>', } return jwt.encode(payload, private_key, algorithm='RS256') 3. インストールアクセストークンを取得する 生成した JWT を使って、インストール単位のアクセストークンを取得します。トークンの有効期限は最大1時間です。 import requests def get_access_token(jwt_token: str) -> str: """GitHub App のインストールアクセストークンを取得する""" install_id = '<GitHub App の Installation ID>' url = f'https://api.github.com/app/installations/{install_id}/access_tokens' headers = { 'Authorization': f'Bearer {jwt_token}', 'Accept': 'application/vnd.github+json', } data = { 'repository': '<対象リポジトリ名>', } response = requests.post(url, json=data, headers=headers) return response.json()['token'] data にさらに細かい権限やリポジトリ指定を加えることで、インストール時の権限からもう一段絞ったトークンを発行できます。 data = { 'repository': '<対象リポジトリ名>', 'permissions': { 'actions': 'write', }, } 最小権限の原則を徹底したい場合は、呼び出しごとに必要な権限だけを付与したトークンを発行するのがおすすめです。 4. GitHub Actions を repository_dispatch で発火する 取得したトークンを使って、 repository_dispatch エンドポイントを叩きます。これで GitHub Actions のワークフローを外部から起動できます。 def workflow_trigger(access_token: str, env: str, branch: str) -> None: """GitHub Actions のワークフローを起動する""" url = 'https://api.github.com/repos/<owner>/<repo>/dispatches' headers = { 'Authorization': f'Bearer {access_token}', 'Accept': 'application/vnd.github+json', } data = { 'event_type': f'deploy-{env}', 'client_payload': { 'branch': branch, }, } response = requests.post(url, json=data, headers=headers) if response.status_code != 204: raise Exception(f'Dispatch failed: {response.status_code} {response.text}') 呼び出される側のワークフローでは、 on: repository_dispatch の types に上で送った event_type を指定しておきます。 on: repository_dispatch: types: [deploy-development] のようにすることで、event_type で送った値と repository_dispatch の types で設定した値が一致したときに発火させることができます。 event_type が deploy-development 発火する event_type が deploy-check 発火しない あとは Slack の ChatBot から Lambda を起動するようにすれば、PAT を使わず Slack から GitHub Actions を叩く 仕組みが完成します。 GitHub 側にも「この App から実行された」という履歴が残るので、監査の観点でも安心です。 おまけ: GraphQL API でブランチ名をあいまい検索する Slack コマンドから発火する場合、ユーザーが打ち込むブランチ名はタイポしがちです。そこで、発行したトークンを使って GraphQL API からブランチ一覧を取得し、あいまいマッチで最も近いブランチを選ぶ 処理を入れておくと便利です。 ブランチ一覧を取得する def fetch_branches(access_token: str, query: str) -> list: """GraphQL API でリポジトリのブランチ一覧を取得する""" QUERY = """ query($owner: String!, $repo: String!, $query: String!) { repository(owner: $owner, name: $repo) { refs(refPrefix: "refs/heads/", first: 100, query: $query) { edges { node { name } } } } } """ headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json', } variables = { 'owner': '<owner>', 'repo': '<repo>', 'query': query, } response = requests.post( 'https://api.github.com/graphql', json={'query': QUERY, 'variables': variables}, headers=headers, ) edges = response.json()['data']['repository']['refs']['edges'] return [edge['node']['name'] for edge in edges] 類似度が最も高いブランチを選ぶ import difflib def search_branch(branches: list, query: str) -> str: """difflib で一番近いブランチ名を選ぶ""" best_match = None highest_ratio = 0.0 for branch in branches: ratio = difflib.SequenceMatcher(None, branch, query).ratio() if ratio > highest_ratio: best_match = branch highest_ratio = ratio return best_match Python 標準ライブラリの difflib.SequenceMatcher で文字列の類似度を計算し、最も近いブランチ名を採用する、という単純なアプローチです。これだけでも「 develp 」を「 develop 」に寄せるくらいの手助けはしてくれます。 まとめ PAT 管理はしんどい。GitHub App に置き換えると運用負荷もセキュリティリスクも下げられる。 JWT → インストールアクセストークン → repository_dispatch の流れで、外部から安全に GitHub Actions を起動できる。 GraphQL API と difflib を組み合わせれば、ブランチ名のタイポにも耐える Slack コマンドが作れる。 GitHub Actions 運用で PAT に消耗している方は、ぜひ GitHub App 化を検討してみてください。 Lambda 実装コード全体 import json, requests, os import time import jwt import difflib def lambda_handler(event, context): env = event.get('env', 'development') target_branch = event.get('branch', 'develop') private_key = aws_parameters('PRIVATE_KEY', decryption=True) jwt_token = generate_github_jwt(private_key) access_token = get_access_token(jwt_token) if target_branch != 'develop': branches = fetch_branches(access_token, target_branch) target_branch = search_branch(branches, target_branch) workflow_trigger(access_token, env, target_branch) return { 'statusCode': 200, 'body': json.dumps('command success!!'), } def aws_parameters(parameter_name: str, decryption: bool = False) -> str: aws_session_token = os.environ['AWS_SESSION_TOKEN'] headers = {'X-Aws-Parameters-Secrets-Token': aws_session_token} response = requests.get( f'http://localhost:2773/systemsmanager/parameters/get?name={parameter_name}&withDecryption={decryption}', headers=headers, ) return json.loads(response.text)['Parameter']['Value'] def generate_github_jwt(private_key: str) -> str: payload = { 'iat': int(time.time()), 'exp': int(time.time()) + 600, 'iss': aws_parameters('GITHUB_CLIENT_ID'), } return jwt.encode(payload, private_key, algorithm='RS256') def get_access_token(jwt_token: str) -> str: install_id = aws_parameters('GITHUB_INSTALL_ID') url = f'https://api.github.com/app/installations/{install_id}/access_tokens' headers = { 'Authorization': f'Bearer {jwt_token}', 'Accept': 'application/vnd.github+json', } data = {'repository': '<対象リポジトリ名>'} response = requests.post(url, json=data, headers=headers) return response.json()['token'] def fetch_branches(access_token: str, query: str) -> list: QUERY = """ query($owner: String!, $repo: String!, $query: String!) { repository(owner: $owner, name: $repo) { refs(refPrefix: "refs/heads/", first: 100, query: $query) { edges { node { name } } } } } """ headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json', } variables = { 'owner': '<owner>', 'repo': '<repo>', 'query': query, } response = requests.post( 'https://api.github.com/graphql', json={'query': QUERY, 'variables': variables}, headers=headers, ) edges = response.json()['data']['repository']['refs']['edges'] return [edge['node']['name'] for edge in edges] def search_branch(branches: list, query: str) -> str: best_match = None highest_ratio = 0.0 for branch in branches: ratio = difflib.SequenceMatcher(None, branch, query).ratio() if ratio > highest_ratio: best_match = branch highest_ratio = ratio return best_match def workflow_trigger(access_token: str, env: str, branch: str) -> None: url = 'https://api.github.com/repos/<owner>/<repo>/dispatches' headers = { 'Authorization': f'Bearer {access_token}', 'Accept': 'application/vnd.github+json', } data = { 'event_type': f'deploy-{env}', 'client_payload': {'branch': branch}, } response = requests.post(url, json=data, headers=headers) if response.status_code != 204: raise Exception(f'Dispatch failed: {response.status_code} {response.text}')
はじめに この記事は、BASE夏のブログリレー5日目の記事です。 こんにちは、BASE でバックエンドエンジニアをしている大塚です。 いきなりですが、エラーログアラート、通知チャンネルには流れてくるものの、日々の開発に追われて誰もすぐには見に行けない——そんな経験はないでしょうか? アラートに気づいた人がログを見にいき、該当コードを grep して……という初動調査は、慣れていても 30 分から 1 時間かかる作業です。 BASE ではエラーや例外を Sentry に集約しているのですが、この初動調査を AI エージェントに任せる Slack Bot「sentry-analyzer」を内製して運用しています。 Slack で Bot に調査を依頼すると、AI が Sentry・New Relic・アプリケーションコードを自動で調べて、原因の仮説と証跡をまとめたレポートをスレッドに返してくれます。 本記事では、sentry-analyzer の仕組みと設計上の工夫、運用して見えてきたことを紹介します。 sentry-analyzer とは sentry-analyzer とは、Sentry から通知されるエラーや例外の調査・修正をしてくれる Bot です。 使い方はシンプルで、Slack のエラー通知チャンネルで Bot に調査を依頼するだけです。さらに、Sentry のアラートが届いたスレッドには Bot が「🔍 調査を開始」ボタンを自動投稿するので、ボタン 1 つでも調査を始められます (工夫したポイントで後述)。 調査結果が Slack のスレッドに返信されてくる様子 すると Bot が裏側で次のような調査を自律的に行い、数分でレポートをスレッドに投稿します。 Sentry からイベント詳細 (スタックトレース、頻度、影響ユーザー数) を取得 New Relic に NRQL を発行して、エラーレート・レイテンシ・直近デプロイなどの関連メトリクスを確認 対象リポジトリのコードを Read / Grep して、スタックトレースが指す実装を読解 以上を突き合わせて、原因の仮説・影響範囲・修正の方向性をレポートにまとめる レポートは 2 層構成にしていて、要約は Slack のスレッドに、チャートやサマリーカード付きの詳細レポートは HTML に変換して社内ホスティングにアップロードし、URL をスレッドに添えます。 レポートを受け取って終わりではなく、そのまま 会話を続けられる のもポイントです。「この仮説の根拠をもっと詳しく」「別の時間帯も見て」とスレッドに返信すると、Bot は調査のコンテキストを保持したまま深掘りしてくれます。 さらに、調査の先にある「修正」まで踏み込んでいます。分析レポートの直後に表示されるボタンを押すか「修正して」とメンションすると、エージェントが分析コンテキストを引き継いでコードを修正し、 Draft PR の作成まで 行います (詳細は後述)。 アーキテクチャ sentry-analyzer は、社内のオペレーション向け AI エージェント群を集約したモノレポ base-operation-ai-agents の一員として、セルフホストの Coolify 上でコンテナとして稼働しています。エージェント本体は 1 つの Node.js プロセスで、構成要素は次のとおりです。 Slack 受け口 : Slack Bolt の Socket Mode。アウトバウンドの WebSocket だけで動くため、インバウンドのエンドポイント公開が不要 エージェント本体 : Claude Agent SDK。ただし SDK に直接依存するのはモノレポ共通の LLM 層 packages/llm だけで、エージェントはその薄いラッパー経由で実行する (後述) 外部データアクセス : Sentry / New Relic へのアクセスは in-process の自作 MCP ツール ( get_sentry_issue / execute_nrql など)、コード読解は SDK 組み込みの Read / Grep / Glob / Bash ツール コード参照 : 調査対象リポジトリのミラーは専用の同期サービス (repo-sync) が共有ボリューム上に定期同期しており、各エージェントはそれを参照してその場で grep できる セッション管理 : Slack のスレッド単位でセッション ID を保持 (TTL 1 時間)。スレッド返信時は SDK のセッション resume で会話を継続 エージェント基盤に Claude Agent SDK を選んだのは、 自前実装を最小にできる からです。LLM エージェントを作ろうとすると、ツール呼び出しのループ、会話履歴の管理、コンテキストの永続化と resume あたりを自分で書くことになりがちですが、SDK はこれらを丸ごと持っています。こちらで書いたのは Slack のハンドラ、MCP ツール、調査手順を記述したプロンプトが中心で、エージェントらしい部分のコードはほとんどありません。 調査の「賢さ」はモデルではなくプロンプト側に寄せています。調査手順はマークダウンのプロンプトテンプレートとして管理しており、「NRQL はこういうクエリをこの順で試す」「trace.id はこの点に注意」といった社内の可観測性ノウハウをここに蓄積しています。運用しながらこのファイルを育てることが、そのまま Bot の調査品質の改善になる構造です。 工夫したポイント 1. 修正 PR 自動作成 — エージェントの責務を「コード修正まで」に絞る 分析セッションは原因箇所と修正方針まで把握しているのに、そのコンテキストを捨てて人間がゼロから修正に着手するのはもったいない。そこで分析の延長で修正 Draft PR まで作れるようにしました。設計で特に意識したのは エージェントに渡す権限を最小にする ことです。 エージェントの責務は「使い捨ての git worktree 内でコードを修正し、PR タイトル・本文を生成する」まで git push と GitHub API 呼び出し (GitHub App 認証) は、エージェントではなくホスト側 (orchestrator) が実行する。認証トークンをエージェント環境に渡さない 修正は共有のコード参照ディレクトリではなく、依頼ごとに切る git worktree で行い、終わったら成功・失敗を問わず削除 さらに、自動生成されたコードがそのまま本番に向かわないよう、機械的なガードを何段か入れています。 ガード 内容 Draft PR 固定 自動マージ機構は持たない。人間がレビューして Ready 化する 変更量上限 一定のファイル数・行数を超える diff は PR を作らず報告のみ 保護パス CI 設定やインフラ定義、lockfile などへの変更はデフォルト拒否 出自明記 PR 本文に AI 生成である旨・元の Sentry Issue・依頼者を明記 トリガーは、曖昧なキーワード判定で自動発火させる形は採らず、 分析レポート直後に表示するボタン (確認ダイアログ付き) と、「修正して」「PR 作って」のような 明示的な依頼メンション の 2 つだけを入口にしています。言い回しの揺れによる誤爆を避けつつ、ボタンのおかげで機能の発見性も上がりました。 2. 使ってもらうための工夫 — アラートに Bot が先回りする 社内ツールは作っただけでは使われません。sentry-analyzer も当初は「メンションの書き方を知っている人だけが使える」状態で、アラートを見た人が Bot の存在を思い出せなければ、そこで初動は止まってしまいます。 そこで、Bot が参加しているチャンネルに Sentry のアラートが届いたら、Bot 自身がそのスレッドに使い方の案内と「🔍 調査を開始」ボタンを自動投稿するようにしました。調査の入口がアラートに必ず現れるので、メンションの書き方を覚えていなくてもボタン 1 つで初動が始まります。 地味ですが効いている配慮が 2 つあります。 分析できないアラートには案内を出さない : Issue ID が抽出できるアラートだけを案内対象にする。ボタンを押したのに「分析できませんでした」と返ってくる体験を作らない ボタン押下後はボタンを外し、「◯◯さんが調査を開始しました」に置き換える : 連打による二重実行を防ぎつつ、誰が調査を始めたかがスレッドに残る 修正 PR の作成をボタンにしたこと (前述) も同じ発想で、「機能があることに気づける導線を、使う場所のすぐそばに置く」ことを意識しています。 3. エージェントを単独で運用せず、社内共通基盤 (モノレポ) に乗せる 社内には sentry-analyzer 以外にも Slack で動くオペレーション向け AI エージェントがあり、それぞれが別リポジトリでデプロイ・環境変数・ログ・LLM 呼び出しを別々に作ると、運用も知見も分断されてしまいます。 実際、sentry-analyzer も過去には単独リポジトリ + EC2 (systemd 常駐) で運用しており、この分断を身をもって感じていました。そこでエージェント群は 1 つのモノレポ base-operation-ai-agents に集約していて、sentry-analyzer もその 1 エージェントとして動いています。 共通基盤に乗ることで得たものは大きく 3 つあります。 LLM 層の共有 : Claude Agent SDK への直接依存は共通パッケージ packages/llm の 1 箇所だけ、という境界規約を CI で検査。共通層は SDK をほぼ素通ししつつ、エラー分類 (型付き例外) と usage 記録だけを足す薄い設計で、SDK のバージョン追従や記録基盤の改善が全エージェントに一度に効く 可観測性 : 運用が軌道に乗ると「誰がどれくらい使っているのか」「1 回の調査に何ターン・いくらかかっているのか」が知りたくなりますが、以前はログが console.log のみで、これに答えられませんでした。いまは共通層が 1 実行ごとの usage (ターン数・トークン量・コスト)・モデル別内訳・ステップトレース (どのツールが何 ms 時点で動いたか) を共有 MySQL に記録し、社内の Web UI から閲覧できます。エラー時もメトリクスは取得されるので、失敗した調査のコストも記録に残ります デプロイの標準化 : push でコンテナイメージがビルドされ自動デプロイ。本番プロセス上でビルドしないので、EC2 時代に踏んだデプロイ事故 (後述) は構造ごと解消 調査対象リポジトリの同期 (repo-sync) のような周辺機能も共用になり、エージェントを増やすたびに作り直す必要がなくなりました。一方でエージェント同士は import し合わない疎結合を保っていて、各エージェントは独立したプロダクトとして開発できます。「共有するのは基盤と規約、プロダクトは独立」というバランスの良い運用ができていると感じています。 運用してみて 初動調査が「投げておけば進む」ものになった 一番大きい変化は、アラート対応の心理的なハードルが下がったことです。従来は「まとまった時間が取れたら見よう」と後回しになりがちだった調査が、Bot に投げておけばレポートが返ってくるので、とりあえず投げる → レポートを見て判断する、という流れになりました。レポートには NRQL の実行結果や該当コードの引用が証跡として付くので、そのままチームの議論の土台になります。 定量的な成果として、運用開始からの約 5 ヶ月で約 150 回の初動調査を実行しています(月 30 回ペース)。 人手なら 30 分〜1 時間かかっていた初動調査が、Bot なら数分でレポートが出てくるようになりました。調査時間そのものは計測していませんが、初動までの速さは体感でも大きく変わっています。 踏んだ罠: 本番サーバ上で npm ci をしてはいけない 順風満帆だったわけではなく、EC2 で運用していた時期にはデプロイ事故もやらかしています。dependabot の major バージョンアップ PR を短時間に連続マージした際、デプロイのたびに本番 EC2 上で npm ci + tsc を走らせる構成だったため、小さいインスタンスのメモリが枯渇。 npm ci が中途半端に死んで node_modules が壊れ、Bot が restart ループに陥りました。 さらに悪いことに、デプロイワークフローが SSM 実行結果のエラーを握り潰す実装になっており、GitHub Actions 上はすべて success 表示。Slack で Bot が無応答なことに気づくまで、数時間の停止を見逃しました。 学びはシンプルです。 本番サーバ上でビルドしない。ビルドして成果物だけ配る CI の success 表示は、実態を exit code に反映していなければ意味がない major バージョンアップの連続マージはそれ自体がリスク この事故は「独自構成の 1 台を独自運用し続けること」自体のリスクを実感させてくれて、共通基盤に乗せる判断を後押しする出来事にもなりました。 今後の展望 症状起点トリアージ : 実際の障害対応の入口は Sentry のアラートだけではなく、「商品ページちょっと重くない?」のような軽い違和感のつぶやきから始まることもあります。Sentry の Issue ID がなくても自然言語の症状記述から対象サービスを推定して初動調査を始められる「症状モード」を検討中です 品質評価の仕組み化 : プロンプトやモデルを変えたとき「調査品質が落ちていないか」を人の目視以外で判定できるよう、形式遵守 → 証跡との整合 (グラウンディング) → プロセス品質 → 結論の妥当性、と段階を分けた評価フレームワークを設計中です エージェント間のノウハウ共有 : モノレポに集まったことで、プロンプトの知見やサブエージェントのレシピをエージェント横断で共有する土台ができました。sentry-analyzer で貯めた調査ノウハウを他のエージェントにも還流させていきます 調査ノウハウの蓄積 : 調査手順のプロンプトテンプレートは、運用で得た知見を足すほど賢くなります。定期的な更新はできていませんが、長期で運用する上で必須の作業です おわりに sentry-analyzer は「エラーの初期調査のコスト削減」に大きく貢献していると感じています。 まだまだ改善の余地はありますが、我々と同じように「アラートは来るが初動が重い」チームの参考になれば幸いです。 明日は、matzzさんの記事です。お楽しみに!













