supabase-js の count クエリが失敗してもnullで、監視は自分の異常に気づけなかった
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
日次ブリーフの連携ヘルス監視は、収集クエリが失敗してもerrorを見ずにcount・dataのnullフォールバックだけを使っていたため、監視自身の失敗が「異常なし」と区別できなかった。
結論
日次ブリーフの連携ヘルス監視は、監視データを集めるクエリ自体が失敗しても「異常なし」と同じ見た目になっていた。 count や data を ?? 0 ?? [] でフォールバックし、error を見ずに使っていたためで、真の0件(正常)と収集失敗(監視が効いていない)が区別できなかった。直し方は単純で、各クエリの error を集約し、1件でもあれば他のどの異常よりも先に必ず表示する。
症状(見つかった経緯)
本番で実際にアラートが漏れて発覚したわけではない。堅牢化のためのレビューで、Reviewer が次の点を指摘した。
- v2 死活・
sync_outboxの滞留・素材未紐付け会社を集める日次ブリーフの「連携ヘルス」セクションが、収集クエリ自体の失敗を一切表現できていない - クエリが失敗しても例外にはならず、
countやdataがnullになるだけなので、呼び出し側がそれを「0件=異常なし」として扱ってしまう経路がある
このブリーフは「一人運用でダッシュボードを毎日見る人はいない」という前提で、異常があれば能動的に Slack へ出すために作られたものだった。その能動的に出す経路自体が、収集失敗時には「何も出さない」という最悪の形で沈黙する。
原因
buildIntegrationHealthLines は、Slack へ出す行を組み立てる純粋関数として書かれている。
// src/lib/integration-health.ts:30-40(修正前)
export function buildIntegrationHealthLines(
input: IntegrationHealthInput
): string[] {
const issues: string[] = [];
const { senderConfigured, v2Health, outbox, unlinkedDeliveringCompanies } =
input;
if (v2Health === "ng") {
型定義にも、収集そのものが失敗したかどうかを表すフィールドが無い。
// src/lib/integration-health.ts:17-26(修正前)
export type IntegrationHealthInput = {
senderConfigured: boolean;
v2Health: V2HealthStatus;
outbox: { pending: number; failed: number; dead: number };
unlinkedDeliveringCompanies: string[];
};
呼び出し元の日次 cron はこう書かれていた。
// src/app/api/cron/notify/route.ts:234-297(修正前、抜粋)
const [outboxPendingRes, outboxFailedRes, outboxDeadRes] = await Promise.all([
admin.from("sync_outbox").select("id", { count: "exact", head: true }).eq("status", "pending"),
admin.from("sync_outbox").select("id", { count: "exact", head: true }).eq("status", "failed"),
admin.from("sync_outbox").select("id", { count: "exact", head: true }).eq("status", "dead"),
]);
const outboxCounts = {
pending: outboxPendingRes.count ?? 0,
failed: outboxFailedRes.count ?? 0,
dead: outboxDeadRes.count ?? 0,
};
// ...
const { data: deliveringCreatives } = await admin
.from("creatives")
.select("company_id")
.in("status", ["配信中", "承認"])
.not("company_id", "is", null)
.limit(2000);
// ...
if (deliveringCompanyIds.length > 0) {
const { data: cos } = await admin
.from("companies")
.select("id, name, kimiteras_v2_advertiser_id")
.in("id", deliveringCompanyIds);
// ...
}
supabase-js はクエリが失敗しても例外を投げず、{data, error, count} をそのまま返す。この呼び出し方は error を一切受け取っていないため、失敗しても outboxPendingRes.count は undefined(→ ?? 0 で 0)に、deliveringCreatives や cos は undefined(→ ?? []・空配列扱い)になるだけで、呼び出し側からは「対象が本当に0件だった」場合と見分けがつかない。
そして buildIntegrationHealthLines は、その関数自身のコメントにある通り「0件(=異常なし)のセクションは出さない」という設計になっている。収集クエリが1本でも失敗すると、outbox は {pending:0, failed:0, dead:0} に、unlinkedDeliveringCompanies は [] に見え、関数から見れば「全部正常」としか判定しようがない。 監視の失敗自体が、監視対象が「異常なし」だったときと完全に同じ入力になっていた。
直し方
型に queryErrors を追加し、収集側で各クエリの error を明示的に拾って集約する。
// src/lib/integration-health.ts:17-31(修正後)
export type IntegrationHealthInput = {
senderConfigured: boolean;
v2Health: V2HealthStatus;
outbox: { pending: number; failed: number; dead: number };
unlinkedDeliveringCompanies: string[];
/**
* ヘルス情報の収集自体に失敗したクエリのエラー(fail-to-green 防止)。
* 監視データが取れない状態を「異常なし」と区別して必ず表示する。
*/
queryErrors?: string[];
};
// src/app/api/cron/notify/route.ts:237-320(修正後、抜粋)
const healthQueryErrors: string[] = [];
const [outboxPendingRes, outboxFailedRes, outboxDeadRes] = await Promise.all([/* ... */]);
for (const [label, res] of [
["outbox(pending)", outboxPendingRes],
["outbox(failed)", outboxFailedRes],
["outbox(dead)", outboxDeadRes],
] as const) {
if (res.error) healthQueryErrors.push(`${label}: ${res.error.message}`);
}
// ...
const { data: deliveringCreatives, error: delivErr } = await admin
.from("creatives")
.select("company_id")
.in("status", ["配信中", "承認"])
.not("company_id", "is", null)
.limit(2000);
if (delivErr) healthQueryErrors.push(`creatives: ${delivErr.message}`);
// ...
const { data: cos, error: cosErr } = await admin
.from("companies")
.select("id, name, kimiteras_v2_advertiser_id")
.in("id", deliveringCompanyIds);
if (cosErr) healthQueryErrors.push(`companies: ${cosErr.message}`);
// ...
const healthLines = buildIntegrationHealthLines({
senderConfigured,
v2Health,
outbox: outboxCounts,
unlinkedDeliveringCompanies,
queryErrors: healthQueryErrors,
});
受け取った側は、queryErrors が1件でもあれば他のどの異常より先に表示する。
// src/lib/integration-health.ts:38-53(修正後)
export function buildIntegrationHealthLines(
input: IntegrationHealthInput
): string[] {
const issues: string[] = [];
const { senderConfigured, v2Health, outbox, unlinkedDeliveringCompanies } =
input;
const queryErrors = input.queryErrors ?? [];
if (queryErrors.length > 0) {
const shown = queryErrors
.slice(0, 2)
.map((e) => escapeSlack(e))
.join(" / ");
issues.push(
`⚠️ ヘルス情報の取得失敗 ${queryErrors.length}件(以下の検知は不完全な可能性): ${shown}`
);
}
if (v2Health === "ng") {
「先頭に必ず出す」のが要点になる。queryErrors のチェックを他のどの if よりも前に置くことで、収集自体が壊れているときは他の判定結果(v2Health や outbox の値)も信用できないという事実を、読む人に最初に伝えられる。
一般化できる形
この形は、この監視に限らない。「0件=正常」という判定ロジックと、「クエリ失敗時にnullへフォールバックする呼び出し方」を組み合わせると、必ず同じ穴が空く。
- supabase-js に限らず、例外を投げずに
{data, error}のようなタプルでエラーを返す SDK では、data(やcount)だけを見て分岐する前にerrorを確認する必要がある - 「異常があるときだけ出す」という設計の監視・アラートは、監視自体の失敗という第三の状態を必ず持つ。正常と異常の2値だけで設計すると、収集失敗は「正常」側に吸収される
- 一人運用のように「誰かが能動的にダッシュボードを見て気づく」という前提が成立しない環境では、この吸収がそのまま「監視が壊れていることに誰も気づけない」という結果に直結する
よくある質問
Q1なぜクエリの失敗が「異常なし」に見えてしまうのですか?
ヘルス行を組み立てる関数は「0件(=異常なし)のセクションは出さない」という方針で書かれています。収集側のcountやdataがクエリ失敗時にnullになり、それを`?? 0`や`?? []`でフォールバックしていたため、真の0件と収集失敗の両方が同じ『0』として渡り、監視自身の異常が握りつぶされていました。
Q2supabase-jsのcountクエリはエラー時に何を返しますか?
supabase-jsは例外を投げず`{data, error, count}`をそのまま返します。クエリが失敗すると`count`はnullになりますが、例外にはならないため呼び出し側が`error`を明示的に確認しない限り失敗は伝わりません。`count ?? 0`のようなフォールバックだけを書くと、失敗と真の0件が区別できなくなります。
Q3なぜ一人運用だとこの誤報がより深刻なのですか?
この日次ブリーフ自体が『ダッシュボードを毎日見る人はいないので、異常があれば能動的にSlackへ出す』という設計思想で作られています。監視の失敗が『異常なし』に化けると、能動的に知らせる唯一の経路が沈黙するため、誰にも気づかれないまま監視自体が壊れた状態が続きます。
確認した環境
- Next.js 16.2.7 / @supabase/supabase-js ^2.106.2 / kimiteras-portal
- 2026-06-12、機能追加コミットと同日のレビュー指摘対応コミットで修正
この記事の根拠
- TypeScriptファイル 17〜26行目コミット dd75ddf
- TypeScriptファイル 30〜40行目コミット dd75ddf
- TypeScriptファイル 234〜297行目コミット dd75ddf
- TypeScriptファイル 17〜31行目コミット dbec9f7
- TypeScriptファイル 38〜53行目コミット dbec9f7
- TypeScriptファイル 237〜320行目コミット dbec9f7
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。