2026-04-17 | AI経営共創パートナーズ | 対象: 05_v3実装計画書.html / Stream B 成果物
設計書04「提案6: 運用監視の最低ライン整備」(CC推奨: 🔴最優先)を具体化する設計書。Cloudflare Workers + D1 + KV 構成の本番運用に必要な監視・ログ保全・アラート・トークン失効事前通知の最低ラインを定義する。実装は子安氏に依頼する最初のブロックに含める前提。
Workers / D1 / KV / Frontend(Cloudflare Pages)を3層で監視する。「保全」「検知」「通知」の責務を分離し、Sentry障害時もLogpushだけは死なない設計とする。
| チャンネル | 用途 | 重要度 |
|---|---|---|
#insta-alert-critical | 投稿失敗・Cron停止・IGトークン失効 | CRITICAL |
#insta-alert-warn | ログイン失敗スパイク・リトライ発生・トークン48h前通知 | HIGH |
#insta-ops-log | Cron正常完了サマリ(1日1回) | INFO |
* * * * *(毎分)で稼働中。本書では同じCronに「失効検知」「エラー通知」処理を相乗りさせる方針(新規Cronは追加しない)stores.token_expires_at が既存。追加マイグレーションは不要posts.processing_started_at / posts.retry_count を監視指標に流用するconsole.log / console.error / 実行メタ(duration, cpuTime, outcome)をR2に永続化| 項目 | 値 |
|---|---|
| バケット名 | insta-auto-v3-logs |
| リージョン | APAC |
| 保存期間 | 90日(R2 Lifecycle で自動削除) |
| パス構造 | workers/{yyyy}/{mm}/{dd}/{hh}/{dataset}-{uuid}.json.gz |
| 形式 | NDJSON(gzip圧縮) |
# 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
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未満の見込み。
| プロジェクト | SDK | DSN環境変数 |
|---|---|---|
| insta-auto-v3-web | @sentry/react | VITE_SENTRY_DSN |
| insta-auto-v3-worker | @sentry/cloudflare | SENTRY_DSN(secret) |
// 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 }, }) }
Cloudflare Workers 用の @sentry/cloudflare は fetch / 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)) }, } )
access_token(IG長期トークン)TOKEN_ENCRYPTION_KEY を含む全環境変数beforeSend フックで必ずマスクする。Sentry 側で PII スクラバーも有効化(Project Settings → Data Scrubbing)。
| # | 種別 | 閾値 | チャンネル | メンション |
|---|---|---|---|---|
| A1 | 投稿失敗 | 1件でも発生(3回リトライ後) | #insta-alert-critical | @channel |
| A2 | Cron失敗 | 直近10分で scheduled 実行ゼロ OR outcome=exception | #insta-alert-critical | @channel |
| A3 | ログイン失敗スパイク | 同一 store_id / IP で 5分間 10回以上 | #insta-alert-warn | なし |
実装場所: packages/worker/src/services/scheduler.ts の publish失敗時ハンドラ。v3で追加された retry_count が MAX_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) }, ], }) }
「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() }, ], })
推奨: new Date().getMinutes() % 15 === 0 のときだけheartbeatを送る(15分に1回)。または、失敗時のみA2としてCRITICALへエスカレーション。
既存の 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分で書き、存在する場合はスキップする。
Instagram Graph API の長期アクセストークンは60日で失効する。v3既存の stores.token_expires_at カラムは存在するが、失効前の自動更新処理(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 |
毎分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 }) } } }
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 } }
| キー | 種別 | スコープ | 用途 | 登録先 |
|---|---|---|---|---|
SENTRY_DSN | secret | Worker | Sentry 送信先 | wrangler secret |
APP_VERSION | var | Worker | Sentry release タグ | wrangler.toml [vars] |
SLACK_WEBHOOK_CRITICAL | secret | Worker | CRITICAL アラート送信先 | wrangler secret |
SLACK_WEBHOOK_WARN | secret | Worker | WARN アラート送信先 | wrangler secret |
CF_ACCOUNT_ID | secret | CI/CD | Logpush job 管理 | GitHub Actions secret |
CF_API_TOKEN | secret | CI/CD | Logpush API 叩く | GitHub Actions secret |
R2_ACCESS_KEY | secret | CI/CD | Logpush宛R2認証 | GitHub Actions secret |
R2_SECRET_KEY | secret | CI/CD | Logpush宛R2認証 | GitHub Actions secret |
VITE_SENTRY_DSN | var | Web | Frontend Sentry(公開鍵なので公開可) | Cloudflare Pages env |
VITE_APP_VERSION | var | Web | Frontend Sentry release | Cloudflare Pages env |
VITE_ENVIRONMENT | var | Web | Sentry environment | Cloudflare Pages env |
# --- 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
VITE_* 系は Cloudflare Pages ダッシュボードの「Settings → Environment variables」で設定する。wrangler CLI ではPages側secretを登録できない(2026年4月時点)。Preview / Production を分けて登録すること。
// 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-1 | R2バケット作成 + Logpush job 設定 | 1.0h | CF APIドキュメント参照+試行錯誤。初回のみ |
| 7-2 | Workers logger.ts 実装+全呼び出し箇所の書き換え | 1.5h | 既存 console.log は約20箇所。置換+構造化 |
| 7-3 | Sentry プロジェクト作成 + Worker SDK 統合 | 1.5h | @sentry/cloudflare は withSentry ラップが必要。Honoとの組み合わせに注意 |
| 7-4 | Sentry Frontend SDK 統合+PIIマスキング | 1.0h | beforeSend とReplay設定のみ |
| 7-5 | alerts.ts 実装(3種Slack送信) | 1.0h | 共通関数1つ+3箇所での呼び出し |
| 7-6 | login_attempts テーブル+マイグレーション | 0.5h | 0003マイグレーション追加のみ |
| 7-7 | ログイン失敗スパイク検知ロジック+dedupe | 1.0h | Cron拡張+KV dedupe |
| 7-8 | IGトークン失効検知+自動refresh | 1.5h | Graph API叩き+updateロジック+3段階通知 |
| 7-9 | 環境変数・secret 登録(本番+staging) | 0.5h | wrangler CLI + Pages ダッシュボード両方 |
| 7-10 | 動作確認(強制エラー発生テスト) | 1.0h | 各アラートが実際にSlackに飛ぶかの手動テスト |
| 小計 | 10.5h | ||
| 7-11 | バッファ(想定外のMeta API挙動など) | +1.5h | IG refresh の仕様差異対応 |
| 合計 | 12.0h | レンジ下限: 8h(バッファ未使用・Logpush省略)/上限: 12h(全て含む) |
| レンジ | 含まれるもの | 削るもの |
|---|---|---|
| 8h(最小) | Sentry + Slack3種 + IGトークン検知 | Logpush(R2連携)→ Sentryで代替 |
| 10h(標準) | 上記+Logpush | login_attempts テーブル(アラートのみKVで簡易化) |
| 12h(完全) | 全項目+動作確認+バッファ | — |
--env staging で事前確認必須insta-auto-v3-logs 作成wrangler secret put SENTRY_DSNwrangler secret put SLACK_WEBHOOK_CRITICALwrangler secret put SLACK_WEBHOOK_WARNVITE_SENTRY_DSNVITE_APP_VERSIONutils/logger.ts 新規services/alerts.ts 新規services/instagram.ts refresh追加services/scheduler.ts 3点拡張index.ts Sentry wrapweb/src/main.tsx Sentry init条件達成後、05_v3実装計画書.html の進捗表「1-B 監視設計書」を 完了 に更新し、Phase 2 統合へ引き継ぐ。