3行で要約すると
- CUJ(Critical User Journey)ベースのダッシュボードを作る前提として、各 CUJ に紐づく Critical API を客観的に特定する必要がありました
- Playwright の route API による fault injection を使い、E2E テストから Critical API を自動抽出する仕組みを作りました
- ある程度汎用的に使えそうなので npm にも置いています:critical-api-finder
はじめに
SREの寺島です。
特定の API のエラーやレイテンシーの悪化が、どのユーザ体験に影響しているのか、容易に判断できるようになりたいと思ったことはありませんか?
MNTSQ は「すべての合意をフェアにする」をミッションに、契約業務を支援するプロダクトを提供しています。
SRE チームでは、顧客向けに提供しているプロダクトにおいて重要な操作のユーザ体験の劣化を早期に検知し、継続的に追うために、CUJ(Critical User Journey)ベースのダッシュボードを作りました。
その構築の過程で、各 CUJ に紐づく Critical API を Playwright のE2E テストから自動抽出するためのツールを作りました。本記事では、このツールを中心に、ダッシュボード構築の流れと合わせて紹介します。
CUJ とは
CUJ は、ユーザがプロダクトを通じて達成したい中核的な操作の流れを指します。
MNTSQでは「契約書をアップロードする」「契約レビューを依頼する」といった操作が代表的な CUJ にあたります。
各 CUJ について、関連する API のメトリクス(エラーレート、P95 レイテンシ等)を一枚の画面で見られるようにしています。

ダッシュボード構築の流れ
このダッシュボードの構築は、以下のような流れで進めました。
- PDM に重要な画面操作(ユーザが毎日必ず行う操作や、壊れたら業務が止まるレベルの操作)をヒアリング
- 各 CUJ に紐づく API の特定・整理
- ダッシュボードの構築
本記事の主題は、この 2 番目の「API の特定・整理」をどう進めたかという話です。この特定・整理を進めるなかで、まず問題になるのが「どの API をメトリクスの対象にするか」という仕分けです。
というのも、MNTSQ のアプリでは、1 つの画面操作の裏側で、主力の処理から補助的なものまで数多くの API が動いています。
- 契約書本体の保存やメタデータの登録のように、失敗がそのままジャーニーの中断に直結する API
- ユーザアイコンの取得や通知バッジ件数のポーリングのように、失敗してもユーザ操作自体は継続できる API
これらを区別せずにすべてダッシュボードに並べてしまうと、重要な変化がノイズに埋もれてしまいます。運用しやすく、かつ意味のあるダッシュボードにするためには、「それが止まるとジャーニーが完遂できない API(Critical API)」を正確に特定し、絞り込む必要がありました。
人手で仕分ける難しさ
いざ Critical な API の仕分けをやろうとすると、意外と根拠を持たせるのが難しいことに気づきました。
- ヒアリングの限界: 開発者に確認しても、フロントエンドのエラーハンドリングの詳細(この API がコケても画面は止まらない、等)まで正確に網羅するのは負担が大きく、属人化も避けられません。
- LLM に推定させる難しさ: Claude Code にコードベースを読ませて Critical な API を推定させる方法も試しました。実行時の振る舞いではなくコード上の文脈から推定する以上、画面遷移後に裏で発火するプリフェッチ系のような「実際に動かさないと見えない」依存関係は取りこぼしやすく、判定根拠の再現性も担保しにくい結果でした。
- メンテナンス性: プロダクトの改修に合わせて API の依存関係は変わるため、その都度手動で調査し直すのは現実的ではありません。
そこで、「人間が判断するのではなく、実際に API を 1 つずつ止めてみて、挙動の変化を機械的に観測すればいいのではないか」と考えました。
Playwright を使ったアプローチ
もっとも単純な方法は、Chrome DevTools の "Block request URL" 機能を使って 1 つずつ API をブロックし、画面操作を手動で確かめていくやり方です。ただし、CUJ ひとつあたり数十個の API があると、これを毎回手作業で繰り返すのは現実的ではありません。手作業の負担はもちろん、人の判定が入ることで属人化や再現性の問題も再発してしまいます。
そこで着目したのが Playwright の Network API です。page.route / context.route を使うと、ブラウザのネットワーク通信をスクリプト側から傍受したり、改変したりできます。たとえば「特定の URL パターンに合致するリクエストだけ 500 を返す」といった操作が数行で書けます。
await context.route("**/api/contracts", async (route) => { await route.fulfill({ status: 500, body: JSON.stringify({ error: "blocked" }) }); });
これを使えば、E2E テストを 1 度書いておくだけで、
- テストを 1 度走らせて、ジャーニー中に呼ばれる API をすべて記録する
- 記録した API を 1 つずつ 500 で短絡しながら、テストを再実行する
- テストが落ちた API を Critical、通った API を非 Critical と判定する
という流れを完全に自動化できます。判定の根拠は「テストが通る/通らない」という二値の客観的なシグナルで、人間の解釈を挟みません。プロダクトに改修が入って依存関係が変わっても、テストを更新して回し直せば最新の Critical API リストが手に入ります。
また、Playwrightを採用した背景としては、ちょうど MNTSQ では Autify から Playwright への E2Eテストの移行プロジェクトが進んでおり、QA が書くテストをそのままインプットとして使える見込みがある、という事情もありました。
動作イメージ
このアプローチをツールとしてまとめたものが critical-api-finder です。Playwright のテストファイルを用意してコマンドを叩くだけで動きます。
npm install -D @playwright/test critical-api-finder npx critical-api-find tests/contract-upload.spec.ts
実行すると、ツールが内部でテストをN+1回繰り返し実行します(最初の 1 回で API を記録 → 各 API を 1 つずつブロックしながら再実行)。終わると critical-api-results/verify-contract-upload.json に結果が出力されます:
{ "journeyId": "contract-upload", "testPath": "tests/contract-upload.spec.ts", "entries": [ { "method": "POST", "pattern": "/api/contracts", "critical": true }, { "method": "GET", "pattern": "/api/contracts/:id", "critical": true }, { "method": "GET", "pattern": "/api/v2/user/me", "critical": false }, { "method": "GET", "pattern": "/api/v2/notifications/count", "critical": false } ] }
仕組み
ツール内部は大きく 4 つのコンポーネントから成ります。 ※ コードは簡略化して載せています。
1. import の書き換え
テストファイルを直接書き換えたくないので、CLI は sibling file(tests/contract-upload.spec.ts → tests/contract-upload.critical.spec.ts)として複製したうえで、@playwright/test の import だけを critical-api-finder 自身に差し替えます。
// テストファイル中のこの import を… import { test, expect } from "@playwright/test"; // 自動的にこちらに書き換える import { test, expect } from "critical-api-finder";
書き換えは正規表現ベースの単純置換です。
const IMPORT_RE = /^([\t ]*import\b[^;]*?\bfrom\s+['"])@playwright\/test(['"])/gm; const REQUIRE_RE = /^([\t ]*(?:const|let|var)\b[^;]*?\brequire\s*\(\s*['"])@playwright\/test(['"]\s*\))/gm; export function rewriteImports(source: string): string { return source .replace(IMPORT_RE, `$1critical-api-finder$2`) .replace(REQUIRE_RE, `$1critical-api-finder$2`); }
critical-api-finder は @playwright/test の公開 API を全 re-export しているので、ユーザのテストはコード変更ゼロで、こちらの拡張 fixture(route handler 入り)を引き継いで動きます。
2. baseline 実行 — API リストの収集
最初の 1 回はブロックなしでテストを走らせ、context.route で全 API を傍受してリスト化します。
await context.route("**/api/**", async (route) => { const method = route.request().method(); const pathname = new URL(route.request().url()).pathname; const normalized = normalizePathname(pathname); appendFileSync(collectFile, `${method} ${normalized}\n`); await route.continue(); });
ここでは route.continue() で素通しさせるだけなので、テストの挙動には影響を与えません。観測したリクエストはあとで重複排除して、ブロックループの対象リストとして使います。
3. パスの正規化
API の path には、リソース ID のように実行のたびに値が変わる動的セグメントが含まれることがあります。たとえば、観測時に /api/contracts/12345 だった path が、次の実行では /api/contracts/67890 のように別の値になっていて、そのままブロック対象として記録しておいても当たらない、ということが起こります。
そこで、観測した path を以下のルールで正規化します。
| セグメント | プレースホルダ |
|---|---|
数値 id (/12345) |
:id |
| UUID | :uuid |
ISO date (/2026-04-22) |
:date |
| 長い hex hash (20+ 桁) | :hash |
実装は順序付きの置換ルールを並べただけのシンプルなものです。
const RULES = [ // UUID(数値 id より先に判定) { regex: /\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/gi, replacement: "/:uuid" }, // ISO date { regex: /\/\d{4}-\d{2}-\d{2}(?=\/|$)/g, replacement: "/:date" }, // 長い hex hash { regex: /\/[0-9a-f]{20,}(?=\/|$)/gi, replacement: "/:hash" }, // 数値 id { regex: /\/\d+(?=\/|$)/g, replacement: "/:id" }, ]; function normalizePathname(pathname: string): string { let result = pathname; for (const { regex, replacement } of RULES) { result = result.replace(regex, replacement); } return result; }
これにより、/api/contracts/12345 も /api/contracts/67890 も同じ /api/contracts/:id という論理的なエンドポイント単位に集約されます。ブロック時は逆にこのプレースホルダを正規表現に展開し、当該パターンに合致するリクエストだけを 500 にする、という流れです。
4. ブロックループ
正規化したパターンを 1 つずつ取り出して、Playwright を再実行します。route handler は同じ場所ですが、今度は対象パターンに合致したリクエストだけを 500 で short-circuit します。
await context.route("**/api/**", async (route) => { const pathname = new URL(route.request().url()).pathname; // ブロック対象パターンに合致するリクエストだけ 500 にする if (blockedRegex.test(pathname)) { await route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Blocked by critical-api-finder" }), }); return; } await route.continue(); });
これでテストが落ちれば Critical、通れば非 Critical と判定します。なお、毎イテレーションで --retries=0 --max-failures=1 を強制することで、Playwright project 側の retry 設定によらず「ブロックした瞬間に exit」させ、無駄な再試行を防いでいます。
ダッシュボードへの組み込み
実際にE2Eテストにこのツールを当てて Critical API のリストを取り出し、そのままダッシュボードのメトリクス対象として組み込みました。ダッシュボードは Datadog 上に Terraform で管理しており、CUJ の定義は次のような形で書いています。
locals { cuj_dashboards = { sample_journey = { title = "ユーザ体験: サンプルジャーニー" service = "sample-service" trace_name = "rack" steps = [ { name = "ステップ 1" endpoints = [{ display_name = "POST /api/sample/foo" resource_name = "resources::v2::fooapi_post_/foo" }] }, { name = "ステップ 2" endpoints = [ { display_name = "GET /api/sample/foo/:id" resource_name = "resources::v2::fooapi_get_/foo/:id" }, { display_name = "GET /api/sample/bar" resource_name = "resources::v2::barapi_get_/bar" }, ] }, ] } # 他の CUJ も同じ形で並べる } } module "cuj_dashboard" { for_each = local.cuj_dashboards # CUJダッシュボード詳細を管理するためのモジュール。本筋ではないため本稿では除外 source = "./modules/cuj_dashboard" dashboard_title = each.value.title service = each.value.service trace_name = each.value.trace_name steps = each.value.steps }
これによって CUJ ごとにダッシュボードが生成され、ステップ単位でエラーレート・レイテンシ・リクエスト数が並ぶ形になります。

最後に
ダッシュボードに載せる Critical API のリストを、人の判断ではなく「テストの通る/通らない」という客観的なシグナルから引けるようになり、属人化とメンテナンスの問題は大きく緩和できました。
副次的な発見として、Playwrightが「回帰を防ぐためのE2Eテストツール」という用途以外にも使えそうだという気づきがありました。fault injection との組み合わせには、依存関係の抽出以外にもいろいろな応用が効きそうで、例えば個人プロダクト用の安上がりな脆弱性診断ツールやカオスエンジニアリングツールなどを似たような仕組みで自作できそうだと思いました。このあたりは今後も探っていきたいと思っています。
同じような課題に取り組んでいる方は、ぜひ覗いてみてください。(フィードバック・PRも歓迎しています)
リポジトリ:github.com/kterashi02/critical-api-finder