「勝手にログアウトされる」の犯人は、オフラインで未サインインのふりをする実装だった
clerk-js は通信に失敗するとセッションを null として返す。鍵は無事なのに未サインイン扱いに倒れ、サインイン画面に放り出される。実測で1日37人。捕まえるまでに計測を3回作り直した。
「勝手にログアウトされる」という報告を1か月追いかけた話の3本目。
前の2つを直しても、報告は止まらなかった。最後に見つかった原因が、いちばん厄介だった。セッションの鍵は無事なのに、通信が一瞬途切れただけで「未サインイン」に倒れていた。
そして、これを捕まえるまでに計測を3回作り直している。記事の本題はむしろそっちだ。
証拠が、どこにも残らなかった
まず困っていたのは、調べる材料が無いことだった。
ログインの失敗は、画面の中にエラーメッセージが出るだけで終わる。クラッシュではないので自前のエラー収集には残らない。解析ツールは無料枠を超えていて取りこぼしていた。
結果どうなるかというと、「ログインできません」という問い合わせが来るたびに、Clerk の管理 API を手で叩いて、当てずっぽうで本人を探すしかなかった。前の2本の記事も、全部その手作業で調べている。
だから計測を入れることにした。認証が失敗した瞬間の事実 — Clerk が返したエラーコード、使ったログイン方法、アプリのバージョン — を自前のバックエンドに残す。
- authEvents テーブル+report(fire-and-forget・5分の重複抑制)
- メールアドレスはクライアントでマスクしてから送る(ab***z@example.com)
- 記録の失敗が認証フローを止めないよう、全経路で握り潰す
計測が本番を壊してはいけないので、記録は撃ちっぱなしで、失敗しても全部握り潰す。メールは端末側でマスクしてから送る。問い合わせの差出人と目視で突き合わせられれば十分で、生アドレスをこちらのDBに増やす理由が無い。
作り直し1回目:計測器が端末に届いていなかった
数日後、集計を見た。0件。
一瞬「じゃあ問題は起きていないのか」と思いかけた。そんなわけがない。報告は毎日来ている。
原因は間抜けだった。計測コードを書いたのは夜で、その日に配信した更新はそれより前のコミットから作られていた。つまり計測器は、まだ誰の端末にも入っていなかった。
「過去30日 0件」は、問題が無いのではなく、測っていなかった。
計測を足したら、それが実際に配信物に乗ったかを確認するまでが1セットだった。ゼロという数字は、正常と未計測の区別がつかない。いちばん危険な形の「異常なし」だった。
作り直し2回目:失敗だけ測っても、何も分からなかった
計測が届き始めて、数字が出た。「2時間で5件」。
これを見て、判断できなかった。
それが100回中5回なのか、6回中5回なのか判断できなかった。判断が180度変わるのに区別できない。
失敗しか記録していなかったからだ。分母が無い数字は、多いのか少ないのか言えない。
そこで成功も記録するようにした。あわせて2つ足した。
- ユーザーが自分でログイン用のシートを閉じた場合を別扱いにする。 同じコードを通るので、分けないと失敗の分子が水増しされる。「キャンセル」は失敗ではない
- 成功したときの経路を記録に残す。 一発で通ったのか、一度詰まってから救済処理で通ったのか。後者の比率が高ければ、数字上は成功でも体感は失敗に近い
この「成功にも種類がある」という発想は、あとで効いた。成功率が高くても、その中身が救済まみれなら体験は壊れている。
作り直し3回目:行き止まりの中身を見る
失敗率は出るようになった。だが「なぜ失敗したか」が分からない。
そこで、行き止まりに落ちた瞬間の状態をそのまま記録に足した。Clerk が何か返したのか / 端末にセッションはいくつあるのか / そのうち生きているのはいくつか。
これで初めて、質の違う2つの失敗を区別できるようになった。
- そもそも何も返ってきていない → 通信か、Clerk 側の問題
- セッションはあるのに使えない → こちらの扱い方の問題
原因がまったく別物なのに、それまでは同じ「失敗」として1つに数えていた。
見えたもの:セッションは生きているのに、画面はサインアウト
材料が揃って、数字を読んだ。出てきたのがこれ。
| 実測(1日あたり) | |
|---|---|
| 生きたセッションがあるのにサインイン画面へ放り出された人 | 37人 |
| 正しいパスワードを入れたのに「すでにサインイン済みです」で行き止まり | 17件 |
後者がひどい。Clerk のセッションは生きているので、サインインしようとすると「もうサインインしてるよ」と弾かれる。だが画面はサインアウト状態なので、ユーザーから見れば「正しいパスワードを入れているのに、意味不明なエラーで入れない」。
ソーシャルログイン側には復帰処理を書いてあったのに、メール側には無かった。片方だけ塞いでいた。
根因:オフラインのとき、未サインインのふりをする
なぜセッションが生きているのに未サインイン扱いになるのか。
clerk-js は、起動時や前面復帰時に Clerk のサーバーへ状態を確認しに行く。この確認が失敗すると、内部でセッションを null として返す実装になっていた。
鍵は端末に無事に残っている。にもかかわらず、通信が一瞬途切れただけで「この人はサインインしていない」という結論になり、サインイン画面へ飛ばされる。
電車、エレベーター、地下、機内モードの切り替え。日常的に起きる。1日37人という数字は、そういうことだった。
前の記事で書いた「認証ドメインに到達できない片肺状態」とも、症状が一部重なる。どちらも通信が理由でログイン状態が壊れて見えるが、片方は端末にセッションが残るのにバックエンド用トークンが出せない状態で、こちらは端末側のライブラリが自分でセッションを無かったことにしている。切り分けができたのは、行き止まりの中身を記録するようにしたあとだった。
対策は2段構え
1段目:公式のオフラインキャッシュを有効にする。
import { resourceCache } from "@clerk/clerk-expo/resource-cache";
<ClerkProvider
publishableKey={publishableKey}
tokenCache={tokenCache}
__experimental_resourceCache={resourceCache}
>
これを渡すと、通信に失敗したときは前回成功時の状態で起動する。通信が正常なときは書き込むだけで、読み込みは発動しない。つまり正常系の挙動は変わらない。
2段目:サインイン画面に着いてしまった人を、黙って戻す。
const sessions = clerk?.client?.activeSessions ?? [];
const alive = sessions.find((s) => s?.status === "active" && s?.id);
if (!alive) return; // 生きたセッションが無ければ何もしない
await clerk.setActive({ session: alive.id });
router.replace("/(tabs)");
すり抜けてサインイン画面まで来てしまった人を、画面を見せる前に復帰させる安全網。
「勝手に復帰させる」が危なくない理由
ここが設計上いちばん気を遣った部分で、書き残しておく価値があると思う。
生きたセッションを見つけたら勝手にログインさせる、というのは一歩間違うと事故になる。本人がサインアウトしたのに勝手に戻される、という最悪の挙動になりうる。
安全だと判断できた根拠はこれ。
意図的なサインアウトはセッションをサーバー側で破棄する。だから、ここに生きたセッションが残っている=本人は消していない。
つまり「生きたセッションがある」という条件そのものが、本人がサインアウトしていないことの証明になっている。サインアウト経路を全部確認して、どれもセッションを破棄してからホームに戻ることを確かめた上で入れた。
もうひとつ、失敗したときの挙動も揃えてある。復帰に失敗しても何もしない。従来どおりのサインイン画面が出るだけで、今日より悪くはならない。安全網を足すときは、外れたときに元の状態に戻るかを先に確認しておくと気が楽だった。
学んだこと
ゼロという計測結果は、正常と未計測の区別がつかない。 計測を追加したら、それが実際に配信物に乗ったかを確認するまでが1セット。「異常なし」がいちばん危ない形で出てくる。
失敗だけ測っても判断できない。分母と、キャンセルの分離と、成功の内訳が要る。 「2時間で5件」は、それ単体では何も意味していなかった。そして成功率が高くても、中身が救済処理まみれなら体験としては失敗に近い。
同じ症状に見えるものを、同じ数字で数えない。 「認証失敗」を1つのカウンタで数えている間は、何も返ってきていない失敗と、セッションがあるのに使えない失敗が混ざったままだった。原因が正反対なのに区別がつかなかった。行き止まりの瞬間の状態をそのまま記録に足したら、その日のうちに切り分けられた。
ライブラリが「安全側」に倒す方向は、自分のアプリにとって安全とは限らない。 通信できないときに未サインイン扱いにするのは、ライブラリの立場では保守的で正しい判断だと思う。だがこちらの体験としては、日常的な瞬断のたびにユーザーを締め出す動作になっていた。