目次を表示する

Bedrockで Production Ready な AI 機能を作る ── 設計・運用・現場の知恵

観測を設計する ── CloudWatch メトリクスと Model Invocation Logging の落とし穴

観測を設計する ── CloudWatch メトリクスと Model Invocation Logging の落とし穴

アンチパターン #7「観測を後付け」の予告編

第 17 章で 9 つのアンチパターンを集約する。本章はそのうち #7「観測を後付け」 の予告編であり、同時に解毒剤でもある。アンチパターンの典型例はこうだ。

本番で「何かおかしい」となったとき、ログを見返してもプロンプト・トークン・コストの相関が追えない。問題の再現にも数日かかる。

これは「Bedrock を運用してみて初めて気付く」では遅い種類の問題だ。LLM ワークロードは プロンプトの 1 行変更でトークン量が 3 倍になり最大トークン数の設定漏れで TPM クォータを即座に食い潰しGuardrails の Mask モードが効いていたつもりが Model Invocation Logs に原文が残っていた ── こうしたことが普通に起きる。発覚したときに「ログがありません」では取り返しがつかない。

だから観測は Day 1 から組み込む。本章では helpdesk-ai に対して以下の 4 層をすべて設計する。

  1. CloudWatch メトリクスAWS/Bedrock namespace の標準メトリクス)
  2. Model Invocation Logging(プロンプト・応答本文を保存)
  3. CloudTrail(管理 API の監査ログ)
  4. X-Ray + サードパーティ統合(分散トレーシング)

そして第 6 章で予告した max_tokens、第 7 章で予告した TimeToFirstToken、第 11 章で予告した Mask モードの落とし穴 ── この 3 つの伏線を本章で実装と数字に落として回収する。

対象:CloudWatch / IAM の基礎を持つ Web/バックエンドエンジニア 難易度:★★★☆☆ 対象バージョン:@aws-sdk/client-bedrock v3 系 / Bedrock 2026 年 6 月時点 想定読了時間:約 30 分


観測の 3 層モデル ── まず全体像

Bedrock の観測は CloudWatch だけでは閉じない。データプレーンの分散トレースを含めると 3 層になる。先に俯瞰しておく。

graph TB
  subgraph App["helpdesk-ai (Lambda / Fastify)"]
    Handler[chat handler<br/>slog 構造化ログ]
  end
  subgraph Bedrock["AWS / Bedrock"]
    Runtime[bedrock-runtime<br/>Converse / ConverseStream]
  end
  subgraph Layer1["Layer 1: Metrics (AWS/Bedrock namespace)"]
    M1[Invocations]
    M2[InvocationLatency / TimeToFirstToken]
    M3[InvocationThrottles / Errors]
    M4[Input/OutputTokenCount<br/>Cache Read/Write Tokens]
  end
  subgraph Layer2["Layer 2: Logs (本文を保存)"]
    L1[Model Invocation Logging<br/>CloudWatch Logs / S3]
    L2[CloudTrail<br/>管理 API 監査]
  end
  subgraph Layer3["Layer 3: Traces (分散追跡)"]
    T1[AWS X-Ray<br/>X-Amzn-Trace-Id 伝搬]
    T2[Langfuse / Datadog<br/>OTLP gRPC]
  end
  Handler -->|InvokeModel| Runtime
  Runtime --> M1
  Runtime --> M2
  Runtime --> M3
  Runtime --> M4
  Runtime --> L1
  Runtime -.管理 API.-> L2
  Handler --> T1
  Handler --> T2

各層がカバーする問いはこうだ。

答えられる問い主な落とし穴
Layer 1 メトリクス「いつ・どのモデルで・何回・どれくらい遅く・どれくらいスロットルされたか」InvocationThrottlesInvocations にも Errors にも入らない
Layer 2 ログ「具体的にどんなプロンプトを送り、何を受け取ったか・誰が呼んだか」Guardrails の Mask モードでも原文が残る
Layer 3 トレース「アプリ全体の中で、Bedrock 呼び出しが他のサービス呼び出しとどう絡んだか」CloudWatch Transaction Search の有効化が前提

この 3 層を「Day 1 でどこまで揃えるか」が観測設計の出発点になる。helpdesk-ai では Layer 1 と Layer 2 を 必須、Layer 3 は AgentCore に乗せたとき(第 10 章)に追加する方針で進める。


Layer 1:CloudWatch メトリクス(AWS/Bedrock namespace)

bedrock-runtime エンドポイントは追加設定なしで AWS/Bedrock namespace にメトリクスを出す。コードに 1 行も足さずに使える、最初の防衛線だ。

必ず押さえるメトリクス

公式 docs の Monitor bedrock-runtime inference using CloudWatch metrics から、Production で必須のものを抜粋する。

メトリクス単位何を見るか
InvocationsCount成功した API 呼び出し数
InvocationLatencyms送信から最終トークンまでの時間(バッチ処理の主指標)
InvocationClientErrorsCount4xx 系。プロンプト不正・権限不足など
InvocationServerErrorsCount5xx 系。AWS 側障害。SLO 監視対象
InvocationThrottlesCountスロットルされたリクエスト数(要注意)
TimeToFirstTokenmsstreaming UX の体感速度指標
InputTokenCount / OutputTokenCountCount入出力トークン数
CacheReadInputTokens / CacheWriteInputTokensCountPrompt Cache の効きを測る
EstimatedTPMQuotaUsageCountTPM クォータ消費の 推定値(capacity planning には使わない)
LegacyModelInvocationsCountレガシー扱いモデルの呼び出し検知

Dimensions は基本 ModelId 1 軸。

ここで読者が見落としやすいのが下の 2 つだ。

落とし穴 1:InvocationThrottlesInvocations にも Errors にもカウントされない

これは公式 docs に明記されている事実で、つまり 「スロットルが起きていてもエラー率ダッシュボードには現れない」。「エラー率 0% なのにユーザーから『応答が来ない』とクレーム」というシナリオは、たいていここで起きる。第 6 章で max_tokens 未設定が「TPM クォータを 64K で予約してしまう」話をしたが、その結果が観測上ここに現れる。

✅ 正しい SLO アラート:
  (InvocationServerErrors + InvocationThrottles) / Invocations > 1%

❌ ありがちな誤り:
  InvocationServerErrors / Invocations > 1%  ← スロットルが見えない

落とし穴 2:EstimatedTPMQuotaUsage は capacity planning に使わない

名前のとおり 推定値 で、AWS 自身が「厳密ではない」と明示している。アラートのトリガーには使えるが、「クォータの 80% に達したから引き上げ申請」のような capacity planning の根拠にはならない。クォータ管理は Service Quotas コンソール側で行う。

TimeToFirstToken ── 第 7 章の伏線回収

第 7 章で「Streaming は総生成時間を縮めないが、TTFT を縮めて『待たされてる感』を消す」と書いた。その TTFT を CloudWatch メトリクスとしてそのまま見られる ── これが TimeToFirstToken だ。streaming 系 API(ConverseStream / InvokeModelWithResponseStream)で自動的に記録される。

helpdesk-ai のチャット UI のように「人間の眼が見ている」場面では、InvocationLatency よりも TimeToFirstToken の方が UX 指標として重要だ。SLO もこちらに置く。

helpdesk-ai SLO 例:
  TimeToFirstToken p95 < 800ms
  InvocationLatency p95 < 8000ms(タスク完了時間)

Prompt Cache のメトリクス(第 15 章の伏線)

CacheReadInputTokensCacheWriteInputTokens は、Prompt Caching を入れたときの効きを測る唯一の数値だ。第 15 章のコスト設計で「Prompt Caching はヒット率 30% 以上を確認してから本番投入」と書くが、その「ヒット率」はこの 2 つから算出する。

キャッシュヒット率 = CacheReadInputTokens / (CacheReadInputTokens + CacheWriteInputTokens + InputTokenCount)

ヒット率が 30% を下回るならむしろコスト増、というのは第 15 章で扱う。本章ではメトリクスとして取れる、ということだけ覚えておけば十分だ。


Layer 2:Model Invocation Logging(本文を保存する)

メトリクスは「数」しか答えない。「具体的にどんなプロンプトでスロットルしたか」「Guardrails が何を Mask したか」「誰が呼んだか」はログ側にしかない。

デフォルトは無効 ── 明示的に ON にする

Model Invocation Loggingデフォルトで無効 だ。Bedrock コンソールの Settings → Model invocation logging から、または PutModelInvocationLoggingConfiguration API で有効化する。

配信先は次のどれか(または両方)。

  • CloudWatch Logs:CloudWatch Logs Insights で即クエリ可能。100KB 超の本文は S3 へ自動で別出し
  • S3:長期保存・分析・Athena 連携が容易
  • 両方:本シリーズの推奨。直近の調査は CloudWatch、長期保管と Athena 分析は S3

対象 API は bedrock-runtime 経由の Converse / ConverseStream / InvokeModel / InvokeModelWithResponseStreambedrock-mantle(OpenAI / Anthropic Messages 互換)経由は対象外である点に注意。

TypeScript で有効化する

@aws-sdk/client-bedrock(control plane)で設定する。@aws-sdk/client-bedrock-runtime ではないので注意。

// infra/observability/enable-logging.ts
import {
  BedrockClient,
  PutModelInvocationLoggingConfigurationCommand,
} from "@aws-sdk/client-bedrock";

const bedrockMgmt = new BedrockClient({
  region: process.env.AWS_REGION ?? "us-east-1",
  maxAttempts: 5,
  retryMode: "adaptive",
});

export async function enableModelInvocationLogging(params: {
  logGroupName: string;       // 例: "/aws/bedrock/helpdesk-ai"
  s3BucketName: string;       // 例: "helpdesk-ai-bedrock-logs"
  loggingRoleArn: string;     // CloudWatch Logs への PutLogEvents 権限を持つロール
}): Promise<void> {
  await bedrockMgmt.send(
    new PutModelInvocationLoggingConfigurationCommand({
      loggingConfig: {
        cloudWatchConfig: {
          logGroupName: params.logGroupName,
          roleArn: params.loggingRoleArn,
          largeDataDeliveryS3Config: {
            // 100KB 超のボディ・画像はここに退避される
            bucketName: params.s3BucketName,
            keyPrefix: "large-data/",
          },
        },
        s3Config: {
          bucketName: params.s3BucketName,
          keyPrefix: "all/",
        },
        // モダリティ別に on/off。helpdesk-ai は text のみ
        textDataDeliveryEnabled: true,
        imageDataDeliveryEnabled: false,
        embeddingDataDeliveryEnabled: false,
        videoDataDeliveryEnabled: false,
      },
    }),
  );
}

設定は アカウント単位・リージョン単位の global setting であり、特定のモデル・呼び出しだけを除外することはできない。マルチテナント環境で「あるテナントだけログを保存しない」は アーキテクチャ側(別アカウントに分離) で実現する必要がある。

ログの形(schema)

実際に出てくる JSON は次の形(公式 docs から抜粋・整形)。

{
  "schemaType": "ModelInvocationLog",
  "schemaVersion": "1.0",
  "timestamp": "2026-06-04T12:00:00Z",
  "accountId": "123456789012",
  "region": "us-east-1",
  "requestId": "abcd1234-5678-efgh-ijkl-mnopqrstuvwx",
  "operation": "Converse",
  "modelId": "us.anthropic.claude-sonnet-4-5",
  "identity": {
    "arn": "arn:aws:sts::123456789012:assumed-role/helpdesk-ai-chat-role/abc"
  },
  "requestMetadata": {
    "feature": "chat",
    "tenant": "hq",
    "session_id": "sess-9f3c"
  },
  "input": {
    "inputContentType": "application/json",
    "inputBodyJson": { "messages": [/* ... 原文 ... */] },
    "inputTokenCount": 425
  },
  "output": {
    "outputContentType": "application/json",
    "outputBodyJson": { "output": {/* ... 応答原文 ... */} },
    "outputTokenCount": 178
  }
}

ここで活用すべきは requestMetadataidentity.arn の 2 つだ。

requestMetadata で任意 K-V タグを付ける

呼び出し側で requestMetadata に Key-Value を載せると、それがそのままログに保存される。helpdesk-ai では機能名・テナント名・セッション ID を載せて、後の集計に使う。

// src/handlers/chat.ts(該当部分のみ)
import { ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
import { bedrock, MODEL_ID } from "../client.js";

export async function chat(params: {
  userText: string;
  tenant: string;
  feature: "chat" | "faq-search" | "ticket-summary";
  sessionId: string;
}) {
  const response = await bedrock.send(
    new ConverseCommand({
      modelId: MODEL_ID,
      messages: [{ role: "user", content: [{ text: params.userText }] }],
      inferenceConfig: { maxTokens: 2000 },
      // ★ ここで requestMetadata を渡す
      requestMetadata: {
        feature: params.feature,
        tenant: params.tenant,
        session_id: params.sessionId,
      },
    }),
  );
  return response;
}

これで Logs Insights から requestMetadata.feature = "chat" でフィルタしたり、テナント別のトークン消費を集計したりできる。第 15 章のコスト設計と直結する Application Inference Profile への tag 付与 とセットで使うと、メトリクス(コスト)とログ(個別呼び出し)の両側からテナント別追跡ができるようになる。

identity.arn で「誰が何トークン使ったか」を集計

identity.arn は IAM principal が記録される。Lambda の実行ロール ARN、SSO セッション、別アカウントからの cross-account assume … すべて生で残る。これを使うと IAM principal 単位のトークン消費 を CloudWatch Logs Insights で集計できる。

-- CloudWatch Logs Insights クエリ:principal 別のトークン消費トップ
fields identity.arn as principal,
       input.inputTokenCount as inTokens,
       output.outputTokenCount as outTokens,
       modelId
| stats sum(inTokens) as totalInput,
        sum(outTokens) as totalOutput,
        count() as calls
        by principal, modelId
| sort totalInput desc
-- requestMetadata の任意タグで集計(テナント別トークン消費)
fields requestMetadata.tenant as tenant,
       requestMetadata.feature as feature,
       input.inputTokenCount as inTokens,
       output.outputTokenCount as outTokens
| stats sum(inTokens) as totalInput,
        sum(outTokens) as totalOutput,
        count() as calls
        by tenant, feature
| sort totalInput desc
-- max_tokens 上限到達(応答打ち切り)の検出
fields requestId, modelId,
       output.outputBodyJson.stopReason as stopReason
| filter stopReason = "max_tokens"
| stats count() by modelId

第 6 章で予告した「max_tokens で打ち切られた応答」は、運用時にはこのクエリで継続監視する。stopReason = "max_tokens" の比率が増えた = ユーザー応答が途中で切れている ということで、maxTokens 設定の見直しサインになる。

❌ 悪い例 vs ✅ 良い例:Mask モードで PII 安全と思い込む(第 11 章の伏線回収)

ここが本章で最も重要な落とし穴だ。第 11 章で Guardrails の Sensitive Information filter を Mask モードANONYMIZE)で設定した。EMAIL を {EMAIL} に置換し、NAME を {NAME} に置換し、外部に出る応答からは PII が消える ── そう信じている。だが Model Invocation Logging を ON にした瞬間に、ログにはこう残る。

{
  "input": {
    "inputBodyJson": {
      "messages": [
        {
          "role": "user",
          "content": [
            { "text": "山田太郎([email protected])の今月の有給残数を教えて" }
          ]
        }
      ]
    }
  }
}

原文がそのまま残っている。Guardrails が動くのはモデル呼び出しの前後だが、Model Invocation Logs はその外側 ── Bedrock がリクエストを受け取った瞬間 ── で取られるからだ。これは 公式 docs(Sensitive information filters) にも次のように明記されている。

The original PII or sensitive information will appear in your model invocation logs, even when masking is enabled in your guardrail.

第 11 章で「Mask モードで PII を消したつもりが、ログに原文が残っていた」と予告したのはこれだ。さらに Guardrails trace の match フィールドにも検出された原 PII が含まれる(アプリ側で位置情報を扱えるよう by design)。「Mask モード = ログも安全」ではない

❌ 悪い例:Mask モードだけで PII 保護完了と判断

// Guardrails で PII を Mask しているからログも安全 ── これが間違い
await enableModelInvocationLogging({ ... });
// Mask モードの Guardrail に頼って終了

✅ 良い例:CloudWatch Log Data Protection を併用する

CloudWatch Logs 側に Log Data Protection ポリシー を別途設定し、配信時点で 2 重にマスクする。

import {
  CloudWatchLogsClient,
  PutDataProtectionPolicyCommand,
} from "@aws-sdk/client-cloudwatch-logs";

const logs = new CloudWatchLogsClient({
  region: process.env.AWS_REGION ?? "us-east-1",
  maxAttempts: 5,
  retryMode: "adaptive",
});

export async function attachLogDataProtection(logGroupName: string) {
  await logs.send(
    new PutDataProtectionPolicyCommand({
      logGroupIdentifier: logGroupName,
      policyDocument: JSON.stringify({
        Name: "helpdesk-ai-pii-protection",
        Version: "2021-06-01",
        Statement: [
          {
            Sid: "audit-and-mask",
            DataIdentifier: [
              "arn:aws:dataprotection::aws:data-identifier/EmailAddress",
              "arn:aws:dataprotection::aws:data-identifier/PhoneNumber",
              "arn:aws:dataprotection::aws:data-identifier/CreditCardNumber",
            ],
            Operation: {
              Audit: { FindingsDestination: {} },
              Deidentify: { MaskConfig: {} },
            },
          },
        ],
      }),
    }),
  );
}

これで CloudWatch Logs Insights から閲覧した時点でマスク済みの内容しか見えなくなる(ただし S3 配信側には適用されないので、S3 配信を ON にしている場合は bucket ポリシー+ KMS CMK + Macie などで別途保護する)。

要するに、「アプリの応答」「ログ」「S3」の 3 か所すべてで独立に PII 保護を設計する ── これが Mask モード落とし穴の脱出法だ。第 11 章 → 本章 → 第 17 章アンチパターン #7 という 3 章またぎの伏線がここで結節する。

Model Invocation Logging のデータフロー(全体像)

sequenceDiagram
  participant App as helpdesk-ai (Lambda)
  participant Runtime as bedrock-runtime
  participant Guard as Guardrails
  participant Model as Foundation Model
  participant Logs as CloudWatch Logs
  participant S3 as S3 Bucket
  participant LDP as Log Data Protection
  App->>Runtime: Converse(requestMetadata, guardrailConfig)
  Runtime->>Logs: 原文 input を記録(Mask 前!)
  Runtime->>Guard: PII Mask 適用
  Guard->>Model: マスク済みプロンプト
  Model-->>Guard: 応答
  Guard-->>Runtime: マスク済み応答
  Runtime->>Logs: 応答を記録
  Runtime-->>App: 応答返却
  alt 本文 > 100KB
    Runtime->>S3: 大容量データ退避
  end
  Logs->>LDP: 配信時に Email/Phone を Mask
  Note over Logs,LDP: ここで初めてログ上の<br/>PII が消える

時系列を 1 枚で理解しておくと、「いつ・どこで PII が露出するか」が頭に入る。


Layer 2 補完:CloudTrail との連携

Model Invocation Logging が「何を投げたか」のデータプレーン側だとすると、CloudTrail は 「誰が何を設定したか」のコントロールプレーン側 を自動で記録する。

  • CreateGuardrail / UpdateGuardrail / DeleteGuardrail
  • CreateAgent / UpdateAgent / PrepareAgent
  • PutModelInvocationLoggingConfiguration(観測設定自体の変更も記録される)
  • PutFoundationModelEntitlement(モデル有効化)

これらは追加設定なしで CloudTrail Management Event として記録される。InvokeModel 系のデータプレーンも CloudTrail には記録されるが、プロンプト・応答の本文は含まれない(メタ情報のみ)。本文を見たいなら Model Invocation Logging を ON にする必要がある ── ここを混同しない。

CloudTrail で取れること:    誰が・いつ・どのリソースを操作したか(メタ情報)
Model Invocation Logs で取れること: 何を投げて、何を受け取ったか(本文)

監査要件としては両方が必要で、片方では足りない。


Layer 3:CloudWatch Generative AI Observability と X-Ray

Out-of-box ダッシュボード

CloudWatch には Generative AI observability という out-of-box ダッシュボード が用意されている。AWS/Bedrock メトリクス・Model Invocation Logs・X-Ray トレースを横断したビューが既製品で提供されるので、helpdesk-ai のような単一プロダクトなら まずこれを開いて足りない軸だけカスタムダッシュボードに足す のがコスト効率がいい。「最初からゼロからカスタムダッシュボード」は労力に見合わないことが多い。

X-Ray トレース(特に AgentCore)

第 10 章で AgentCore に移行した後は AgentCore + X-Ray で distributed tracing をかける。AgentCore Runtime は標準で X-Ray を吐く設定が可能で、X-Amzn-Trace-Id ヘッダで cross-service の trace context を伝搬する。前提として CloudWatch Transaction Search の有効化が必要 ── これを忘れると X-Ray ビューに何も出てこなくて 1 時間溶ける、というのが現場でよく起きる。

// Lambda 側で X-Amzn-Trace-Id を Bedrock 呼び出しに伝搬
import { ConverseCommand } from "@aws-sdk/client-bedrock-runtime";

const command = new ConverseCommand({
  modelId: MODEL_ID,
  messages: [/* ... */],
  inferenceConfig: { maxTokens: 2000 },
});
// SDK middleware で自動的に伝搬される(明示注入は通常不要)
const response = await bedrock.send(command);

サードパーティ統合の選び方

LLM 観測の OSS / SaaS は乱立気味だが、Bedrock との相性と「いつ選ぶか」は概ね決まっている。

ツール立ち位置Bedrock 統合helpdesk-ai でいつ使うか
LangfuseOSS 主流。self-hostableAWS 公式 blog で導入解説あり。OTLP で AgentCore 直結プロンプト管理・実験追跡を内製化したい段階
LangSmithLangChain 純正LangChain Bedrock LLM クラスで自動トレースLangChain を使っている場合に最高
HeliconeDrop-in proxyBedrock SDK の Endpoint 差し替えで導入コードを変えずに即計測したい PoC 段階
Datadog LLM Observabilityエンタープライズ標準Python SDK で Bedrock 公式サポート既存 Datadog 文化のある会社
Arize PhoenixML 評価寄りOTel 経由drift / eval をプロダクト化する段階

AgentCore に乗せて Langfuse に流す場合、AgentCore 内蔵の observability と 二重計測を避ける ために次の 2 つを切る。

# AgentCore Runtime 設定
disable_otel=True

# 環境変数(ADOT との競合回避)
export DISABLE_ADOT_OBSERVABILITY=true

helpdesk-ai ではまず CloudWatch + Model Invocation Logging で完結 させ、AgentCore に移行する第 10 章のタイミングで Langfuse を追加する、というのが現実的な順序だ。最初からフルスタックを入れない。


helpdesk-ai 全体の観測設計

ここまでの要素を helpdesk-ai に統合する。CDK で観測リソースを アプリと同じスタックに置く のがポイントだ。「観測を後付け」を防ぐ最も効く構造的な手段は 「観測なしではデプロイできない」状態を IaC で固定すること

// infra/cdk-stack.ts(観測関連の抜粋)
import * as cdk from "aws-cdk-lib";
import * as logs from "aws-cdk-lib/aws-logs";
import * as cw from "aws-cdk-lib/aws-cloudwatch";
import * as actions from "aws-cdk-lib/aws-cloudwatch-actions";
import * as sns from "aws-cdk-lib/aws-sns";
import * as bedrock from "aws-cdk-lib/aws-bedrock";

export class HelpdeskAiObservabilityStack extends cdk.Stack {
  constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // 1. ログ保管先
    const logGroup = new logs.LogGroup(this, "BedrockLogGroup", {
      logGroupName: "/aws/bedrock/helpdesk-ai",
      retention: logs.RetentionDays.SIX_MONTHS,
      removalPolicy: cdk.RemovalPolicy.RETAIN,
    });

    // 2. CloudWatch Logs に Data Protection を attach(PII の二重マスク)
    new logs.CfnDataProtectionPolicy(this, "BedrockLogDataProtection", {
      logGroupIdentifier: logGroup.logGroupName,
      policyDocument: JSON.stringify({
        Name: "helpdesk-ai-pii-protection",
        Version: "2021-06-01",
        Statement: [
          {
            Sid: "mask-pii",
            DataIdentifier: [
              "arn:aws:dataprotection::aws:data-identifier/EmailAddress",
              "arn:aws:dataprotection::aws:data-identifier/PhoneNumber",
            ],
            Operation: { Deidentify: { MaskConfig: {} } },
          },
        ],
      }),
    });

    // 3. アラート用 SNS トピック(オンコール宛)
    const alertTopic = new sns.Topic(this, "BedrockAlertTopic", {
      displayName: "helpdesk-ai Bedrock alerts",
    });

    // 4. InvocationThrottles 急増アラート
    const throttleAlarm = new cw.Alarm(this, "ThrottleAlarm", {
      metric: new cw.Metric({
        namespace: "AWS/Bedrock",
        metricName: "InvocationThrottles",
        dimensionsMap: { ModelId: "us.anthropic.claude-sonnet-4-5" },
        statistic: "Sum",
        period: cdk.Duration.minutes(5),
      }),
      threshold: 10,
      evaluationPeriods: 2,
      treatMissingData: cw.TreatMissingData.NOT_BREACHING,
      alarmDescription: "Bedrock スロットルが 5 分間に 10 件超: max_tokens 見直し",
    });
    throttleAlarm.addAlarmAction(new actions.SnsAction(alertTopic));

    // 5. TimeToFirstToken の劣化アラート(チャット UX SLO)
    const ttftAlarm = new cw.Alarm(this, "TtftAlarm", {
      metric: new cw.Metric({
        namespace: "AWS/Bedrock",
        metricName: "TimeToFirstToken",
        dimensionsMap: { ModelId: "us.anthropic.claude-sonnet-4-5" },
        statistic: "p95",
        period: cdk.Duration.minutes(5),
      }),
      threshold: 1500,
      evaluationPeriods: 3,
      alarmDescription: "TTFT p95 が 1.5 秒超: チャット UX 劣化",
    });
    ttftAlarm.addAlarmAction(new actions.SnsAction(alertTopic));

    // 6. コスト急増の早期検知(入力トークンの急増)
    const tokenSurgeAlarm = new cw.Alarm(this, "TokenSurgeAlarm", {
      metric: new cw.Metric({
        namespace: "AWS/Bedrock",
        metricName: "InputTokenCount",
        dimensionsMap: { ModelId: "us.anthropic.claude-sonnet-4-5" },
        statistic: "Sum",
        period: cdk.Duration.hours(1),
      }),
      threshold: 5_000_000, // 1 時間あたり 500 万トークン
      evaluationPeriods: 1,
      alarmDescription: "入力トークン急増: プロンプト変更によるトークン爆発の疑い",
    });
    tokenSurgeAlarm.addAlarmAction(new actions.SnsAction(alertTopic));
  }
}

ポイントは 3 つ。

  1. InvocationThrottles を SLO の分子に必ず含める(前述のとおりエラー扱いされないため)
  2. TimeToFirstToken の p95 をチャット UX SLO の主指標にする
  3. InputTokenCount の急増を「プロンプト変更によるトークン爆発」の早期検知に使う ── 「プロンプトを 1 行いじったら入力トークンが 3 倍になっていた」は実際よくある

Lambda 側の構造化ログ

CloudWatch メトリクスと Model Invocation Logging は Bedrock 側の自動記録だが、それと アプリ側の slog ログを request-id で繋いでおく ことで、初めて「ユーザー → アプリ → Bedrock」の追跡が完成する。

// src/handlers/chat.ts
import pino from "pino";
import { ConverseCommand } from "@aws-sdk/client-bedrock-runtime";
import { bedrock, MODEL_ID } from "../client.js";

const logger = pino({ level: "info" });

export async function chat(req: {
  userId: string;
  tenant: string;
  feature: "chat" | "faq-search";
  text: string;
}) {
  const sessionId = crypto.randomUUID();
  const start = Date.now();
  try {
    const response = await bedrock.send(
      new ConverseCommand({
        modelId: MODEL_ID,
        messages: [{ role: "user", content: [{ text: req.text }] }],
        inferenceConfig: { maxTokens: 2000 },
        requestMetadata: {
          feature: req.feature,
          tenant: req.tenant,
          session_id: sessionId,
        },
      }),
    );
    // ★ requestId はメトリクス/ログ/アプリログを横断する鍵
    logger.info(
      {
        requestId: response.$metadata.requestId,
        sessionId,
        tenant: req.tenant,
        feature: req.feature,
        userId: req.userId,
        inputTokens: response.usage?.inputTokens,
        outputTokens: response.usage?.outputTokens,
        stopReason: response.stopReason,
        latencyMs: Date.now() - start,
      },
      "bedrock.converse.success",
    );
    return response;
  } catch (err) {
    logger.error(
      { sessionId, tenant: req.tenant, err },
      "bedrock.converse.error",
    );
    throw err;
  }
}

これで「ある問い合わせがおかしい」と来たとき、sessionId から Lambda の slog → requestId から Model Invocation Logs → モデル別ダッシュボードまで芋づる式に辿れる。「観測を後付け」を脱出する具体的な道具立て がこれだ。


観測は揃った ── 次は守りに行く

ここまでで helpdesk-ai の観測は揃った。

  • CloudWatch メトリクスInvocationThrottles を SLO の分子に、TimeToFirstToken をチャット UX SLO に、CacheRead/WriteInputTokens で Prompt Cache 効果測定の準備
  • Model Invocation Logging:CloudWatch Logs と S3 の両方へ。requestMetadata で機能・テナント・セッション、identity.arn で principal 別の集計
  • Mask モードの落とし穴:Guardrails の Mask は ログには効かない。CloudWatch Log Data Protection を併用して二重マスク
  • CloudTrail + X-Ray + Generative AI Observability:監査・分散追跡・標準ダッシュボードで補完
  • CDK で観測リソースをアプリと同じスタックに:「観測なしではデプロイできない」状態を構造的に作る

これで何が起きたかを後から再現できる土台ができた。だが「何が起きたか追跡できる」と「そもそも起きないようにする」は別の話だ。第 14 章ではセキュリティを設計する ── IAM 強制パターン・VPC Endpoint・KMS で「Guardrails のついていない呼び出しを禁止する」「インターネットを経由させない」「鍵を顧客管理にする」を IaC で固める。

観測は事後の道具、セキュリティは事前の道具。両者が揃って初めて Production Ready と呼べる。


章末まとめ

  • 観測は Day 1 から組み込む。後付けは「コスト爆発・スロットル・PII 漏洩」の発覚を遅らせる
  • AWS/Bedrock namespace の InvocationThrottlesInvocations にも Errors にも入らない。SLO の分子に必ず含める
  • TimeToFirstToken は streaming UX の主指標。チャット UI の SLO はここに置く
  • Model Invocation Logging はデフォルト無効。requestMetadata でテナント・機能タグ、identity.arn で principal 別の集計ができる
  • Guardrails の Mask モードは ログには効かない。CloudWatch Log Data Protection を必ず併用する
  • CloudTrail(管理 API)と Model Invocation Logging(本文)は役割が違う。両方が必要
  • サードパーティ(Langfuse / Datadog 等)は AgentCore 移行後に追加するのが現実的な順序
  • 次章では IAM 強制パターン・VPC Endpoint・KMS で「事前の防御」を設計する