AIインスタ担当 v3 運用監視設計書

2026-04-17 | AI経営共創パートナーズ | 対象: 05_v3実装計画書.html / Stream B 成果物

📌 本書の位置づけ

設計書04「提案6: 運用監視の最低ライン整備」(CC推奨: 🔴最優先)を具体化する設計書。Cloudflare Workers + D1 + KV 構成の本番運用に必要な監視・ログ保全・アラート・トークン失効事前通知の最低ラインを定義する。実装は子安氏に依頼する最初のブロックに含める前提。

目次
  1. 全体アーキテクチャ
  2. Cloudflare Logpush(R2保全)
  3. Sentry 導入(Frontend / Workers)
  4. Slack Webhook アラート 3種
  5. IGトークン失効48h前通知(F-06拡張)
  6. 環境変数・シークレット一覧
  7. 工数見積もりの根拠(8〜12h)
  8. 実装チェックリスト

1. 全体アーキテクチャ

1.1 監視レイヤ構成

Workers / D1 / KV / Frontend(Cloudflare Pages)を3層で監視する。「保全」「検知」「通知」の責務を分離し、Sentry障害時もLogpushだけは死なない設計とする。

Workers 実行
log/error
Logpush
R2バケット
Sentry
例外・パフォーマンス
Cron 集計
スパイク検知
Slack Webhook
3チャンネル

1.2 チャンネル方針

チャンネル用途重要度
#insta-alert-critical投稿失敗・Cron停止・IGトークン失効CRITICAL
#insta-alert-warnログイン失敗スパイク・リトライ発生・トークン48h前通知HIGH
#insta-ops-logCron正常完了サマリ(1日1回)INFO

1.3 v3 既存構成との関係

2. Cloudflare Logpush(R2保全)

2.1 目的

2.2 R2バケット設計

項目
バケット名insta-auto-v3-logs
リージョンAPAC
保存期間90日(R2 Lifecycle で自動削除)
パス構造workers/{yyyy}/{mm}/{dd}/{hh}/{dataset}-{uuid}.json.gz
形式NDJSON(gzip圧縮)

2.3 設定コマンド(wrangler CLI ではなく Cloudflare API 経由)

# 1. R2バケット作成
wrangler r2 bucket create insta-auto-v3-logs

# 2. Logpush job 作成(Workers Trace Events データセット)
curl -X POST "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/logpush/jobs" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "insta-auto-v3-workers-trace",
    "dataset": "workers_trace_events",
    "destination_conf": "r2://insta-auto-v3-logs/workers/{DATE}?account-id='"$CF_ACCOUNT_ID"'&access-key-id='"$R2_ACCESS_KEY"'&secret-access-key='"$R2_SECRET_KEY"'",
    "output_options": {
      "field_names": ["Event","EventTimestampMs","Outcome","ScriptName","Exceptions","Logs","DispatchNamespace","CPUTime","WallTime"],
      "timestamp_format": "rfc3339",
      "batch_prefix": "",
      "output_type": "ndjson"
    },
    "filter": "{\"where\":{\"key\":\"ScriptName\",\"operator\":\"eq\",\"value\":\"insta-auto-v2-api\"}}",
    "enabled": true
  }'

# 3. R2 Lifecycle(90日で自動削除)
wrangler r2 bucket lifecycle add insta-auto-v3-logs \
  --prefix "workers/" \
  --expire-days 90

2.4 Workers 側の構造化ログ

Logpush は console.log 出力をそのまま拾うため、全ログをJSON文字列化する logger ユーティリティを追加する。

// packages/worker/src/utils/logger.ts
type LogLevel = 'info' | 'warn' | 'error'

export function log(level: LogLevel, event: string, ctx: Record<string, unknown> = {}): void {
  const line = JSON.stringify({
    ts: new Date().toISOString(),
    level,
    event,
    ...ctx,
  })
  if (level === 'error') console.error(line)
  else if (level === 'warn') console.warn(line)
  else console.log(line)
}

// 呼び出し例
log('info', 'post.publish.ok', { storeId, postId, durationMs })
log('error', 'post.publish.fail', { storeId, postId, error: err.message, retryCount })
⚠ 料金に関する注意

Logpush to R2 はWorkers Paid プラン($5/月〜)が必要。Free プランでは利用不可。R2のストレージコストは0.015 USD/GB/月(Class A 書き込みは1,000回 = 0.0036 USD)。v3想定トラフィック(1店舗あたり月100投稿 × 10店舗 = 1,000 exec/月 + Cron 毎分 = 43,200/月)ではR2コストは月$1未満の見込み。

3. Sentry 導入(Frontend / Workers)

3.1 プロジェクト構成

プロジェクトSDKDSN環境変数
insta-auto-v3-web@sentry/reactVITE_SENTRY_DSN
insta-auto-v3-worker@sentry/cloudflareSENTRY_DSN(secret)

3.2 Frontend(React + Vite)初期化

// packages/web/src/main.tsx 冒頭に追加
import * as Sentry from '@sentry/react'
import { BrowserTracing, Replay } from '@sentry/react'

if (import.meta.env.PROD && import.meta.env.VITE_SENTRY_DSN) {
  Sentry.init({
    dsn: import.meta.env.VITE_SENTRY_DSN,
    environment: import.meta.env.VITE_ENVIRONMENT ?? 'production',
    release: import.meta.env.VITE_APP_VERSION,
    integrations: [
      new BrowserTracing({ tracePropagationTargets: [/^\/api\//] }),
      new Replay({ maskAllText: true, blockAllMedia: true }),
    ],
    tracesSampleRate: 0.1,        // 10% パフォーマンストレース
    replaysSessionSampleRate: 0,    // セッション録画はエラー時のみ
    replaysOnErrorSampleRate: 1.0,
    beforeSend(event) {
      // 承認tokenを含むURLをマスク
      if (event.request?.url?.includes('/approval/')) {
        event.request.url = event.request.url.replace(/\/approval\/[^/?]+/, '/approval/[REDACTED]')
      }
      return event
    },
  })
}

3.3 Workers(Hono)初期化

Cloudflare Workers 用の @sentry/cloudflarefetch / scheduled 両方のエントリをラップする。Workers は V8 isolate 再利用のため、init はモジュールトップレベルで1回だけ呼ぶ。

// packages/worker/src/index.ts 冒頭
import { Hono } from 'hono'
import * as Sentry from '@sentry/cloudflare'
import type { Env } from './types'

const app = new Hono<{ Bindings: Env }>()

// 全ルートで例外をSentryに送信
app.onError((err, c) => {
  Sentry.captureException(err, {
    tags: { route: c.req.path, method: c.req.method },
    extra: { storeId: c.req.header('x-store-id') },
  })
  return c.json({ error: 'Internal Server Error' }, 500)
})

export default Sentry.withSentry(
  (env: Env) => ({
    dsn: env.SENTRY_DSN,
    environment: env.ENVIRONMENT,
    tracesSampleRate: 0.1,
    release: env.APP_VERSION,
    // 承認tokenのクエリ引数をマスク
    beforeSend(event) {
      if (event.request?.query_string) {
        event.request.query_string = (event.request.query_string as string)
          .replace(/token=[^&]+/, 'token=[REDACTED]')
      }
      return event
    },
  }),
  {
    async fetch(request, env, ctx) {
      return app.fetch(request, env, ctx)
    },
    async scheduled(event, env, ctx) {
      const { runScheduledJobs } = await import('./services/scheduler')
      ctx.waitUntil(runScheduledJobs(env, event))
    },
  }
)

3.4 PII マスキング(必須)

🚨 送信禁止データ

beforeSend フックで必ずマスクする。Sentry 側で PII スクラバーも有効化(Project Settings → Data Scrubbing)。

4. Slack Webhook アラート 3種

4.1 アラート分類

#種別閾値チャンネルメンション
A1投稿失敗1件でも発生(3回リトライ後)#insta-alert-critical@channel
A2Cron失敗直近10分で scheduled 実行ゼロ OR outcome=exception#insta-alert-critical@channel
A3ログイン失敗スパイク同一 store_id / IP で 5分間 10回以上#insta-alert-warnなし

4.2 A1: 投稿失敗アラート

実装場所: packages/worker/src/services/scheduler.ts の publish失敗時ハンドラ。v3で追加された retry_countMAX_RETRY=3 に到達した時点で発火。

// packages/worker/src/services/alerts.ts(新規)
import type { Env } from '../types'
import { log } from '../utils/logger'

export async function sendSlackAlert(env: Env, payload: {
  level: 'critical' | 'warn'
  title: string
  fields: Array<{ label: string; value: string }>
}) {
  const webhook = payload.level === 'critical'
    ? env.SLACK_WEBHOOK_CRITICAL
    : env.SLACK_WEBHOOK_WARN
  if (!webhook) {
    log('warn', 'slack.webhook.missing', { level: payload.level })
    return
  }

  const color = payload.level === 'critical' ? '#e53e3e' : '#d69e2e'
  const body = {
    text: payload.title,
    attachments: [{
      color,
      fields: payload.fields.map(f => ({ title: f.label, value: f.value, short: true })),
      footer: `insta-auto-v3 / ${env.ENVIRONMENT}`,
      ts: Math.floor(Date.now() / 1000),
    }],
  }

  const res = await fetch(webhook, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  })
  if (!res.ok) {
    log('error', 'slack.webhook.fail', { status: res.status })
  }
}

scheduler.ts の失敗分岐で呼び出し:

// publish 失敗時
if (retryCount >= MAX_RETRY) {
  await sendSlackAlert(env, {
    level: 'critical',
    title: '🔴 投稿失敗(リトライ上限到達)',
    fields: [
      { label: 'store', value: store.name },
      { label: 'post_id', value: post.id },
      { label: 'retry', value: `${retryCount}/${MAX_RETRY}` },
      { label: 'error', value: err.message.slice(0, 200) },
    ],
  })
}

4.3 A2: Cron失敗アラート

「Cronが動いていないこと」を内部から検知できないため、外形監視を併設する。Cron 実行ごとに D1 の cron_heartbeat テーブルに UPSERT し、別系統(Cloudflare Workers の別スクリプト or 外部サービス)から15分間隔で SELECT する。

簡易案: Cron 正常完了時に Slack Webhook へ「heartbeat」を投げ、Slack Workflow Builder の「メッセージが15分間ない場合」トリガーで警報する。こちらの方がインフラ追加ゼロで済む。

// scheduler.ts 末尾
await sendSlackAlert(env, {
  level: 'warn', // heartbeat は warn チャンネル
  title: '💓 cron heartbeat',
  fields: [
    { label: 'processed', value: `${processed}件` },
    { label: 'failed', value: `${failed}件` },
    { label: 'ts', value: new Date().toISOString() },
  ],
})
⚠ heartbeat を毎分飛ばすとSlackがうるさい

推奨: new Date().getMinutes() % 15 === 0 のときだけheartbeatを送る(15分に1回)。または、失敗時のみA2としてCRITICALへエスカレーション。

4.4 A3: ログイン失敗スパイク

既存の auth.ts ログイン失敗時に D1 の login_attempts(新規テーブル)へ記録し、Cron(毎分)で直近5分の失敗数を集計する。

-- migrations/0003_login_attempts.sql
CREATE TABLE login_attempts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  store_id TEXT,
  ip TEXT NOT NULL,
  success INTEGER NOT NULL, -- 0 or 1
  attempted_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_login_attempts_window ON login_attempts(attempted_at, ip);
// scheduler.ts(毎分Cron内)
const spikes = await env.DB.prepare(`
  SELECT ip, store_id, COUNT(*) AS cnt
  FROM login_attempts
  WHERE success = 0 AND attempted_at > datetime('now', '-5 minutes')
  GROUP BY ip, store_id
  HAVING cnt >= 10
`).all()

for (const s of spikes.results) {
  await sendSlackAlert(env, {
    level: 'warn',
    title: '⚠ ログイン失敗スパイク検知',
    fields: [
      { label: 'ip', value: s.ip as string },
      { label: 'store_id', value: (s.store_id as string) ?? 'unknown' },
      { label: 'count(5min)', value: String(s.cnt) },
    ],
  })
}

// 古いレコードは7日で削除(別途VACUUM)
await env.DB.prepare(`DELETE FROM login_attempts WHERE attempted_at < datetime('now', '-7 days')`).run()
重複通知の抑制

同じspikeを毎分検知し続けると通知地獄になるため、KVに alert_dedupe:{ip}:{store_id} を TTL 30分で書き、存在する場合はスキップする。

5. IGトークン失効48h前通知(F-06拡張)

5.1 背景

Instagram Graph API の長期アクセストークンは60日で失効する。v3既存の stores.token_expires_at カラムは存在するが、失効前の自動更新処理(F-06)・失敗時の人手対応への橋渡しが未実装。本章ではそのCron拡張を定義する。

5.2 F-06 Cron の責務拡張

段階条件アクション
1. 自動更新token_expires_at が現在から7日以内IG API /refresh_access_token を叩き、新トークンで update
2. 更新失敗時の警告1が3回連続失敗 OR 失効まで48h以内Slack #insta-alert-warn へ「手動更新要」通知
3. 失効直前の最終警告失効まで24h以内Slack #insta-alert-critical@channel付きで通知
4. 失効後token_expires_at を過ぎた該当store の status=token_expired にマークし、投稿Cronをskip

5.3 実装箇所

毎分Cron(既存)に相乗りさせるが、全店舗をチェックするのは毎時1回だけに間引く(負荷回避)。

// packages/worker/src/services/scheduler.ts
export async function checkTokenExpiry(env: Env) {
  const now = new Date()
  // 毎時0分だけ実行
  if (now.getMinutes() !== 0) return

  const { results } = await env.DB.prepare(`
    SELECT id, name, token_expires_at, access_token
    FROM stores
    WHERE token_expires_at IS NOT NULL
      AND token_expires_at > datetime('now')
  `).all<{ id: string; name: string; token_expires_at: string; access_token: string }>()

  for (const store of results) {
    const expiresAt = new Date(store.token_expires_at)
    const hoursLeft = (expiresAt.getTime() - now.getTime()) / (3600 * 1000)

    // 7日以内 → 自動更新試行
    if (hoursLeft <= 24 * 7) {
      const refreshed = await refreshIgLongToken(store.access_token, env)
      if (refreshed.ok) {
        await env.DB.prepare(
          `UPDATE stores SET access_token = ?, token_expires_at = ? WHERE id = ?`
        ).bind(refreshed.token, refreshed.expiresAt, store.id).run()
        log('info', 'token.refresh.ok', { storeId: store.id })
        continue
      }
      log('warn', 'token.refresh.fail', { storeId: store.id, error: refreshed.error })
    }

    // 24h以内 → CRITICAL / 48h以内 → WARN(dedupe は KV で1日TTL)
    const dedupeKey = `token_alert:${store.id}:${expiresAt.toISOString().slice(0,10)}`
    if (await env.TEMP_IMAGES.get(dedupeKey)) continue

    if (hoursLeft <= 24) {
      await sendSlackAlert(env, {
        level: 'critical',
        title: '🔴 IGトークン失効24h前(手動更新必須)',
        fields: [
          { label: 'store', value: store.name },
          { label: 'expires_at', value: store.token_expires_at },
          { label: 'hours_left', value: hoursLeft.toFixed(1) },
        ],
      })
      await env.TEMP_IMAGES.put(dedupeKey, 'sent', { expirationTtl: 86400 })
    } else if (hoursLeft <= 48) {
      await sendSlackAlert(env, {
        level: 'warn',
        title: '⚠ IGトークン失効48h前(自動更新未完)',
        fields: [
          { label: 'store', value: store.name },
          { label: 'expires_at', value: store.token_expires_at },
        ],
      })
      await env.TEMP_IMAGES.put(dedupeKey, 'sent', { expirationTtl: 86400 })
    }
  }
}

5.4 refreshIgLongToken の仕様

packages/worker/src/services/instagram.ts に追加。Meta Graph API の仕様上、長期トークンは失効24h前以降でないとrefresh不可な場合があるため、エラーコード 190/463 は「手動更新要」として扱う。

export async function refreshIgLongToken(
  currentToken: string,
  env: Env
): Promise<{ ok: true; token: string; expiresAt: string } | { ok: false; error: string }> {
  const url = new URL('https://graph.instagram.com/refresh_access_token')
  url.searchParams.set('grant_type', 'ig_refresh_token')
  url.searchParams.set('access_token', currentToken)

  const res = await fetch(url.toString())
  if (!res.ok) {
    return { ok: false, error: `status=${res.status}` }
  }
  const data = await res.json() as { access_token: string; expires_in: number }
  const expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString()
  return { ok: true, token: data.access_token, expiresAt }
}

6. 環境変数・シークレット一覧

6.1 一覧表

キー種別スコープ用途登録先
SENTRY_DSNsecretWorkerSentry 送信先wrangler secret
APP_VERSIONvarWorkerSentry release タグwrangler.toml [vars]
SLACK_WEBHOOK_CRITICALsecretWorkerCRITICAL アラート送信先wrangler secret
SLACK_WEBHOOK_WARNsecretWorkerWARN アラート送信先wrangler secret
CF_ACCOUNT_IDsecretCI/CDLogpush job 管理GitHub Actions secret
CF_API_TOKENsecretCI/CDLogpush API 叩くGitHub Actions secret
R2_ACCESS_KEYsecretCI/CDLogpush宛R2認証GitHub Actions secret
R2_SECRET_KEYsecretCI/CDLogpush宛R2認証GitHub Actions secret
VITE_SENTRY_DSNvarWebFrontend Sentry(公開鍵なので公開可)Cloudflare Pages env
VITE_APP_VERSIONvarWebFrontend Sentry releaseCloudflare Pages env
VITE_ENVIRONMENTvarWebSentry environmentCloudflare Pages env

6.2 wrangler.toml への登録手順

# --- 1. 非機密の var は wrangler.toml に追記 ---
# packages/worker/wrangler.toml
[vars]
ENVIRONMENT = "production"
APP_VERSION = "v3.0.0"

# --- 2. secret は wrangler CLI で登録(gitには入れない) ---
cd packages/worker

wrangler secret put SENTRY_DSN
# → プロンプトに "https://xxxxx@oXXX.ingest.sentry.io/XXX" を貼る

wrangler secret put SLACK_WEBHOOK_CRITICAL
# → "https://hooks.slack.com/services/T.../B.../..." を貼る

wrangler secret put SLACK_WEBHOOK_WARN

# --- 3. 登録済みsecret の確認(値は見えない) ---
wrangler secret list

# --- 4. 環境別に分ける場合(production/staging) ---
wrangler secret put SENTRY_DSN --env production
wrangler secret put SENTRY_DSN --env staging
⚠ Cloudflare Pages(Frontend)は別系統

VITE_* 系は Cloudflare Pages ダッシュボードの「Settings → Environment variables」で設定する。wrangler CLI ではPages側secretを登録できない(2026年4月時点)。Preview / Production を分けて登録すること。

6.3 Env型の更新

// packages/worker/src/types.ts に追加
export interface Env {
  DB: D1Database
  TEMP_IMAGES: KVNamespace
  ENVIRONMENT: string
  TOKEN_ENCRYPTION_KEY: string
  ANTHROPIC_API_KEY: string
  // --- 本書で追加 ---
  SENTRY_DSN: string
  APP_VERSION: string
  SLACK_WEBHOOK_CRITICAL: string
  SLACK_WEBHOOK_WARN: string
}

7. 工数見積もりの根拠(8〜12h)

7.1 工数内訳

#作業工数根拠
7-1R2バケット作成 + Logpush job 設定1.0hCF APIドキュメント参照+試行錯誤。初回のみ
7-2Workers logger.ts 実装+全呼び出し箇所の書き換え1.5h既存 console.log は約20箇所。置換+構造化
7-3Sentry プロジェクト作成 + Worker SDK 統合1.5h@sentry/cloudflarewithSentry ラップが必要。Honoとの組み合わせに注意
7-4Sentry Frontend SDK 統合+PIIマスキング1.0hbeforeSend とReplay設定のみ
7-5alerts.ts 実装(3種Slack送信)1.0h共通関数1つ+3箇所での呼び出し
7-6login_attempts テーブル+マイグレーション0.5h0003マイグレーション追加のみ
7-7ログイン失敗スパイク検知ロジック+dedupe1.0hCron拡張+KV dedupe
7-8IGトークン失効検知+自動refresh1.5hGraph API叩き+updateロジック+3段階通知
7-9環境変数・secret 登録(本番+staging)0.5hwrangler CLI + Pages ダッシュボード両方
7-10動作確認(強制エラー発生テスト)1.0h各アラートが実際にSlackに飛ぶかの手動テスト
小計10.5h
7-11バッファ(想定外のMeta API挙動など)+1.5hIG refresh の仕様差異対応
合計12.0hレンジ下限: 8h(バッファ未使用・Logpush省略)/上限: 12h(全て含む)

7.2 レンジの内訳

レンジ含まれるもの削るもの
8h(最小)Sentry + Slack3種 + IGトークン検知Logpush(R2連携)→ Sentryで代替
10h(標準)上記+Logpushlogin_attempts テーブル(アラートのみKVで簡易化)
12h(完全)全項目+動作確認+バッファ

7.3 前提条件(時間内に収めるための)

8. 実装チェックリスト

📦 インフラ準備

🔑 シークレット登録

💻 コード実装

🧪 動作確認

✅ 本書完了条件

条件達成後、05_v3実装計画書.html の進捗表「1-B 監視設計書」を 完了 に更新し、Phase 2 統合へ引き継ぐ。

作成: Stream B セッション(別CC)/ 最終更新: 2026-04-17