観測を設計する ── CloudWatch メトリクスと Model Invocation Logging の落とし穴
アンチパターン #7「観測を後付け」の予告編
第 17 章で 9 つのアンチパターンを集約する。本章はそのうち #7「観測を後付け」 の予告編であり、同時に解毒剤でもある。アンチパターンの典型例はこうだ。
本番で「何かおかしい」となったとき、ログを見返してもプロンプト・トークン・コストの相関が追えない。問題の再現にも数日かかる。
これは「Bedrock を運用してみて初めて気付く」では遅い種類の問題だ。LLM ワークロードは プロンプトの 1 行変更でトークン量が 3 倍になり、最大トークン数の設定漏れで TPM クォータを即座に食い潰し、Guardrails の Mask モードが効いていたつもりが Model Invocation Logs に原文が残っていた ── こうしたことが普通に起きる。発覚したときに「ログがありません」では取り返しがつかない。
だから観測は Day 1 から組み込む。本章では helpdesk-ai に対して以下の 4 層をすべて設計する。
- CloudWatch メトリクス(
AWS/Bedrocknamespace の標準メトリクス) - Model Invocation Logging(プロンプト・応答本文を保存)
- CloudTrail(管理 API の監査ログ)
- X-Ray + サードパーティ統合(分散トレーシング)
そして第 6 章で予告した max_tokens、第 7 章で予告した TimeToFirstToken、第 11 章で予告した Mask モードの落とし穴 ── この 3 つの伏線を本章で実装と数字に落として回収する。
対象:CloudWatch / IAM の基礎を持つ Web/バックエンドエンジニア 難易度:★★★☆☆ 対象バージョン:
@aws-sdk/client-bedrockv3 系 / 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 メトリクス | 「いつ・どのモデルで・何回・どれくらい遅く・どれくらいスロットルされたか」 | InvocationThrottles は Invocations にも 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 で必須のものを抜粋する。
| メトリクス | 単位 | 何を見るか |
|---|---|---|
Invocations | Count | 成功した API 呼び出し数 |
InvocationLatency | ms | 送信から最終トークンまでの時間(バッチ処理の主指標) |
InvocationClientErrors | Count | 4xx 系。プロンプト不正・権限不足など |
InvocationServerErrors | Count | 5xx 系。AWS 側障害。SLO 監視対象 |
InvocationThrottles | Count | スロットルされたリクエスト数(要注意) |
TimeToFirstToken | ms | streaming UX の体感速度指標 |
InputTokenCount / OutputTokenCount | Count | 入出力トークン数 |
CacheReadInputTokens / CacheWriteInputTokens | Count | Prompt Cache の効きを測る |
EstimatedTPMQuotaUsage | Count | TPM クォータ消費の 推定値(capacity planning には使わない) |
LegacyModelInvocations | Count | レガシー扱いモデルの呼び出し検知 |
Dimensions は基本 ModelId 1 軸。
ここで読者が見落としやすいのが下の 2 つだ。
落とし穴 1:InvocationThrottles は Invocations にも 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 章の伏線)
CacheReadInputTokens と CacheWriteInputTokens は、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 / InvokeModelWithResponseStream。bedrock-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
}
}
ここで活用すべきは requestMetadata と identity.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/DeleteGuardrailCreateAgent/UpdateAgent/PrepareAgentPutModelInvocationLoggingConfiguration(観測設定自体の変更も記録される)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 でいつ使うか |
|---|---|---|---|
| Langfuse | OSS 主流。self-hostable | AWS 公式 blog で導入解説あり。OTLP で AgentCore 直結 | プロンプト管理・実験追跡を内製化したい段階 |
| LangSmith | LangChain 純正 | LangChain Bedrock LLM クラスで自動トレース | LangChain を使っている場合に最高 |
| Helicone | Drop-in proxy | Bedrock SDK の Endpoint 差し替えで導入 | コードを変えずに即計測したい PoC 段階 |
| Datadog LLM Observability | エンタープライズ標準 | Python SDK で Bedrock 公式サポート | 既存 Datadog 文化のある会社 |
| Arize Phoenix | ML 評価寄り | 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 つ。
InvocationThrottlesを SLO の分子に必ず含める(前述のとおりエラー扱いされないため)TimeToFirstTokenの p95 をチャット UX SLO の主指標にする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/Bedrocknamespace のInvocationThrottlesはInvocationsにも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 で「事前の防御」を設計する