Sentryでバグを自動検知させようとバグ含みアプリを仕込んでみたら思ったより大変だった話
IT技術
はじめに
グループワークで「Sentry によるバグ自動検知 → Claude AI による自動修正 PR」というワークフローを構築するプロジェクトに取り組みました。
グループは数人で役割を分担し、バグが含まれているアプリの作成チームと、Sentryの検知の仕組みを作るチームの二手に分かれて対応しました。
自分はバグが含まれているアプリの作成の方で対応を進めていきました。
どういったアプリが良いか話し合ったところ、動きが分かりやすい「じゃんけん」をコンピュータとさせて結果を出すアプリを作成しました。
作業に使える時間も限られているので、簡単なレベルで。
そのアプリの中にわざとバグを仕込んで Sentry に検知させ、自動修正の仕組みが正しく動くかを検証しようとしていました。
ところが、仕込んだバグよりも先に意図していなかった別の問題が本番環境で出てしまいました。
Sentry検知したときの流れ
まず、Sentry検知したときの流れはこうです。
検知後の詳細な流れは検知チームに任せているのでここでは細かいことは省きます。
1アプリで例外が発生
2 ↓
3Sentry がエラーを検知
4 ↓
5登録されているメールアドレスにエラーのメールが届くアプリ作成側としては、まずはSentryがエラー検知したメールが届くことが目標です。
じゃんけんアプリの概要
じゃんけんアプリの概要です。
グー・チョキ・パーをボタンで選んでコンピュータと対戦する、シンプルなじゃんけんゲームです。
それだけだとエラーの種類をあまり仕込めないので、履歴を残したり、自動でコンピュータと対戦する機能も入れてみました。
機能一覧:
- グー・チョキ・パーのボタンを押して対戦する
- 対戦結果をデータベースに保存して履歴として表示する
- 10 秒ごとに自動で対戦を繰り返すスケジューラ機能(開始/停止・間隔変更が可能)
- Google アカウントでログイン(特定の Google Groups メンバーのみアクセス可能)
スケジューラ機能を入れることで、人が操作しなくても自動で例外を発生させ続けることができます。
Sentry の検知実験で何度もエラーを発生させたりするために入れてみました。
技術スタック:
アプリの言語や使用したものは以下の通りです。
直近の作業でKotlinを触ることが多かったので、この形にさせていただきました。
| 種別 | 採用技術 |
|---|---|
| 言語 | Kotlin 1.9 |
| フレームワーク | Spring Boot 3.5 |
| DB | MySQL 8.0(ローカルは Docker Compose) |
| ORM | MyBatis |
| 認証 | Spring Security + Google OAuth2 |
| インフラ | Google Cloud Run + Cloud SQL |
| IaC | Terraform |
アプリの構築
アーキテクチャ
レイヤー構成はシンプルな 4 層です。
1ブラウザ (index.html — Vanilla JS)
2 ↓ REST API (JSON)
3Controller 層
4 ↓
5Service 層(じゃんけん判定・トランザクション)
6 ↓
7Mapper 層(MyBatis)
8 ↓
9MySQL
10
11別フロー: Scheduler(10 秒ごと)→ Service → Mapperスケジューラの実装
最初は @Scheduled アノテーションで実装しましたが、実行間隔を画面から変えられるようにしたかったため、TaskScheduler を使って動的に制御できる方式に切り替えました。
1// schedulerのServiceクラス(抜粋)
2private val enabled = AtomicBoolean(false)
3private val intervalMs = AtomicLong(10_000)
4
5fun toggle(): Boolean {
6 return enabled.getAndSet(!enabled.get()).not()
7}スケジューラスレッドと HTTP リクエストスレッドが並行して動くため、AtomicBoolean と AtomicLong でスレッドセーフにしています。
構築中に遭遇した予期せぬバグ(下準備段階)
アプリ本番に仕込む「実験バグ」とは別に、アプリを作っている段階でもいろいろな問題に遭遇しました。
簡単なものから、ちょっと考えて整理しないといけないものまで、ざっと並べてみます。
static ファイルがリロードしても変わらない
index.html を修正して保存したのに、ブラウザをリロードしても古い画面のままです。
原因は Spring Boot の仕様で、起動時に static/ 配下のファイルをメモリにキャッシュするためです。
起動後にファイルを変更しても、アプリを再起動するまで反映されません。
開発効率化には spring-boot-devtools を導入するとホットリロードが有効になります。
Kotlin から Spring の Java ライブラリを使うと
ThreadPoolTaskScheduler を設定するコードを書いたら、コンパイルエラーになりました。
1// バグのあるコード
2val scheduler = ThreadPoolTaskScheduler()
3scheduler.poolSize = 1 // Val cannot be reassigned
4scheduler.threadNamePrefix = "..." // Val cannot be reassignedJava の setter メソッドが Kotlin の val プロパティとして解釈されてしまう問題です。
apply {} ブロックで初期化して対応する感じでした。
1// 修正後
2fun taskScheduler(): TaskScheduler = ThreadPoolTaskScheduler().apply {
3 poolSize = 1
4 setThreadNamePrefix("auto-play-")
5}Sentry 検知用のバグを仕込む
アプリを作成し、その中に Sentry の検知実験用に意図的なバグを仕込んで、本番環境にデプロイしました。
今回試しに仕込んだのは以下の感じです。
仕込んだバグ:グー・チョキ・パーの割り当てロジックを壊す
スケジューラが自動対戦で使う計算式を、わざと間違えた値に変えました。
1// model/Hand.kt — 意図的に壊したコード
2fun fromSecond(second: Int): Hand = fromKey(second % 5 + 1)
3// ↑ 正しくは % 3まず、グー・チョキ・パーのkeyを1〜3としました。
時間の秒数とか適当な数字を取ってきて、それを決まった数で割った余りを出す感じです。
上記の式だと second % 5 の結果は 0〜4 なので、key は 1〜5 になります。
でも Hand の key は 1〜3(グー・チョキ・パー)しかないため、key が 4 か 5 になると IllegalArgumentException が発生します。
これを検知させようという感じです。
自動で対戦するスケジューラは、設定した秒ごとに動き続けるようにしたため、これを実行するとエラーが定期的に発生し続けます。
1ERROR [scheduling-1] o.s.s.s.TaskUtils$LoggingErrorHandler :
2Unexpected error occurred in scheduled task
3
4java.lang.IllegalArgumentException: 無効なキーです: 4 (1:グー, 2:チョキ, 3:パー)
5 at Hand$Companion.fromKey(Hand.kt:10)
6 at Hand$Companion.fromSecond(Hand.kt:12)なお、@Scheduled や TaskScheduler では、メソッド内で例外が発生してもスケジューラ自体は止まりません。
設定した秒ごとにエラーが繰り返し発生し続けるため、連続したエラーのSentry 検知実験としても対応しています。
ところが、別の問題が出てきた
バグを仕込んで本番環境にデプロイしたあと、Sentry の検知を確認しようとアプリを操作しました。
すると、仕込んだバグとは関係のない問題が出てきました。
ブラウザでグー・チョキ・パーのボタンを押すと:
1エラーが発生しましたというダイアログは出るが、エラーのログやSentry検知もされない。
自動実行の開始/停止ボタンを押すと:
- ダイアログも出ない
- 画面も変わらない
- 何も起きない
サーバーのログを確認しても、POST /api/games/play の受信ログが一切出ていません。
1DEBUG [auto-play-1] [自動実行] スキップ(停止中)
2DEBUG [auto-play-1] [自動実行] スキップ(停止中)
3# POST リクエストのログが一切ない ← おかしいこれは仕込んだバグとは別の問題で、リクエストがサーバーのアプリコードに届いていないようでした。
原因の調査
ブラウザの開発者ツールで確認する
「エラーが発生しました」というダイアログだけでは原因がわからないので、ブラウザの開発者ツール(F12)を開いて、Network タブでリクエストを確認しました。
すると、POST /api/games/play のステータスが 403 Forbidden になっていました。
1{
2 "timestamp": "2026-03-25T09:48:19.000+00:00",
3 "status": 403,
4 "error": "Forbidden",
5 "path": "/api/games/play"
6}リクエストがうまくいっていないようでした。
色々と調べてみたら、Spring Security が原因みたいでした。
CSRF 保護が原因だった
調べてみると、Spring Security はデフォルトで CSRF(クロスサイトリクエストフォージェリ)保護が有効になっているようでした。
CSRF(クロスサイトリクエストフォージェリ)というのは、こういう攻撃です。
11. ユーザーが じゃんけんアプリ にログインしている
22. 別タブで悪意のあるもの(例えば evil.example.com というものだったり)を開いてしまう
33. 悪意のあるもの(evil.example.com)の JavaScript が、こっそり じゃんけんアプリ に POST を送る
44. ブラウザはログイン済みのセッションクッキーを自動で付けて送ってしまう
55. サーバーは「正規ユーザーのリクエストだ」と思って処理してしまうこれを防ぐために Spring Security は、POST リクエストに CSRF トークン(ランダムな秘密の値)の添付を義務づけています。
トークンがないリクエストは 403 で弾かれます。
何が問題だったか
SecurityConfig.kt に CSRF に関する記述がありませんでした。
1// SecurityConfig.kt — csrf の設定が何もない = デフォルト(有効)のまま
2http
3 .authorizeHttpRequests { ... }
4 .oauth2Login { ... }
5 .logout { ... }一方、フロントエンドの JavaScript はトークンを一切送っていませんでした。
1// index.html — CSRF トークンなしで POST
2const res = await fetch('/api/games/play', {
3 method: 'POST',
4 headers: { 'Content-Type': 'application/json' },
5 body: JSON.stringify({ player_hand: hand })
6 // X-XSRF-TOKEN ヘッダーがない
7});結果として、こういう流れになっていました。
1ブラウザ
2 └─ POST /api/games/play(CSRF トークンなし)
3 ↓
4Spring Security の CsrfFilter
5 └─ 「トークンがない!」→ 403 Forbidden を返す ← ここで止まる
6 ↓
7対象のController には届かない(ログも出ない)ログが出なかったのは、リクエストがアプリのコードに届く前に Spring Security のフィルターで止められていたからでした。
「エラーが発生しました」と無反応ボタンの理由も判明
Spring Security が返す 403 レスポンスには message フィールドがありません。
1const err = await res.json();
2alert(err.message || 'エラーが発生しました');
3// ↑ message がないので undefined → フォールバックの文言が表示される自動実行ボタンが無反応だったのは、エラー処理が書かれていなかったためです。
1async function toggleScheduler() {
2 const res = await fetch('/api/scheduler/toggle', { method: 'POST' });
3 if (res.status === 401) { location.href = '/login'; return; }
4 // 403 のケースが考慮されていない
5 const data = await res.json(); // { status: 403, error: "Forbidden" }
6 updateSchedulerUI(data.enabled, data.interval_ms); // どちらも undefined → 無反応
7}対応方法
本番環境で AJAX から POST するには、JavaScript 側で CSRF トークンを取得してヘッダーに付ける必要があります。
Spring Security の CookieCsrfTokenRepository.withHttpOnlyFalse() を使うと、サーバーが Cookie でトークンを渡してくれるようになります。
JavaScript からは document.cookie でトークンを読み取り、X-XSRF-TOKEN ヘッダーに付けて送ります。
1// SecurityConfig.kt
2http
3 .csrf { csrf ->
4 csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
5 }1// index.html
2function getCsrfToken() {
3 return document.cookie
4 .split('; ')
5 .find(row => row.startsWith('XSRF-TOKEN='))
6 ?.split('=')[1];
7}
8
9const res = await fetch('/api/games/play', {
10 method: 'POST',
11 headers: {
12 'Content-Type': 'application/json',
13 'X-XSRF-TOKEN': getCsrfToken()
14 },
15 body: JSON.stringify({ player_hand: hand })
16});これで Sentry の検知実験を改めて進めることができます。
学んだこと
1. Spring Security はデフォルトで CSRF が有効
Spring Security を導入したら、何も設定しなくても CSRF 保護は有効です。
AJAX で POST するアプリを作るなら、最初から「CSRF トークンをどう扱うか」を決めておくべきでした。
| 認証方式 | CSRF 対応方針 |
|---|---|
| セッション認証 + AJAX | Cookie でトークンを渡して JS から送る |
| JWT 認証 | Cookie を使わないので CSRF は不要 → csrf.disable() でOK |
2. ログが出ないときは「届いていない」を疑う
ログが出ないとき、「アプリのコードにバグがある」とすぐ考えてしまいましたが、そもそもリクエストが届いていませんでした。
Spring Security のフィルター、ロードバランサー、ネットワーク層など、アプリより手前で止まっている可能性があります。
調査の最初の一手はブラウザの Network タブでステータスコードを確認することです。
3. エラーメッセージの文言を鵜呑みにしない
「エラーが発生しました」はフォールバックで出ていた汎用メッセージでした。
エラーダイアログの文言は、原因を示していないことがあります。
4. スケジューラ内の例外は止まらず繰り返す
@Scheduled や TaskScheduler でのメソッド内例外はスケジューラを止めません。
ログに ERROR が繰り返し流れたら、スケジュールされたメソッドの中を真っ先に確認するべきです。
これは今回は Sentry 検知実験の利点として活用しましたが、意図しない例外が繰り返される状況では早期に気づくことが大切です。
5. Kotlin から Spring の Java ライブラリを初期化するときはを使う
Java の setter メソッドが Kotlin の val プロパティとして解釈されてコンパイルエラーになることがあります。
apply {} ブロックで初期化すれば解決できます。
ここまで確認や整理、修正してようやくSentryのバグ検知まで漕ぎ着けました。
さいごに
あえてバグを残してコードをpushしたりデプロイする、ってことは基本的に実務では無いことだと思うので、考えることが普段と違っていろんなところでつまづいてしまいました。
それでもまあ、なんとかバグ検知まで進めたので良かったのかなと思います。
普段あまり考えないような感じでアプリ作成していって、いろいろと勉強になりました。
ライトコードでは、エンジニアを積極採用中!
ライトコードでは、エンジニアを積極採用しています!カジュアル面談等もございますので、くわしくは採用情報をご確認ください。
採用情報へ
元ファストフード店長代理のJava系ITエンジニア。 Webサイト系の開発や運用をいくらか経験し、 現在はAndroidアプリ開発を主に担当したり。 休みの日はゲームとか風景写真撮りに行ったりとかマラソンしたりとか。






