Chrome拡張のダッシュボードに大きな数字を並べると、実装者が意図していない強い主張に読まれがちです。たとえば「トラッカーイベント 151件」という表示は、151回の追跡をすべて阻止したことまで意味するのでしょうか。「リンクトラッカー 35件」は、ユーザーが35回クリックした履歴なのでしょうか。
結論から言うと、どちらも違います。この記事では、自分が開発しているMailshade 1.0.6の実装を例に、観測・保存・集計・表示上の主張を分ける設計を整理します。製品紹介ではなく、ローカルファーストなChrome拡張でレポートを作るときに再利用できる考え方が中心です。
図1:Mailshade 1.0.6の実UI。数値と送信者名はスクリーンショット用の決定論的デモデータです。
まず、1行のイベントが何を意味するかを固定する
レポートの入力は、ローカルIndexedDBに保存したイベント行です。イベントには少なくとも次のような区別があります。
type LocalTrackerEvent = {
id: string
timestamp: number
type: 'pixel' | 'link'
client: 'gmail' | 'outlook' | 'office365' | 'superhuman' | 'yahoo' | 'protonmail'
domain: string
senderDomain?: string
}
重要なのは、type: 'link' を「クリック」と定義しないことです。これは、開いたメッセージ内に既知の追跡リダイレクトが存在したというローカルな検出記録です。クリック操作や遷移履歴を保存したものではありません。
同じように、イベントという名前だけで「ネットワーク要求を必ずブロックできた」とも言い切れません。既知のピクセルドメインをDNRルールで止める経路と、DOM上でリソースを検出してメッセージへ帰属させる経路は、証拠の境界が異なるからです。
集計は「選択された行」から毎回作る
レポートで期間とメールクライアントを選んだら、まずその条件に合うイベント行だけを取得します。そこから各指標を計算します。
const rows = await events
.where('timestamp')
.between(from, to, true, true)
.filter(matchesSelectedClient)
.toArray()
const trackerEvents = rows.length
const uniqueSenders = new Set(
rows.flatMap(row => row.senderDomain ? [row.senderDomain] : [])
).size
const linkTrackers = rows.filter(row => row.type === 'link').length
このモデルなら、UIの3つの数字をそれぞれ説明できます。
| 表示 | 実際に数えているもの | 数えていないもの |
|---|---|---|
| トラッカーイベント | 選択期間・選択クライアントに一致したローカルイベント行 | あらゆる追跡手法、完全なブロック成功数 |
| ユニーク送信者 | イベントに紐づいた送信者ドメインの種類 | 人物の一意性、メールアドレスの総数 |
| リンクトラッカー |
type === 'link' のイベント行 |
クリック数、訪問数、遷移成功数 |
「何を数えたか」と同じくらい、「何を数えていないか」を仕様に書くことが大切です。
図2:観測から表示までの証拠レベル。右へ進むほど、実装が保証していない意味を足さないようにします。
元のURLをそのまま履歴に残さない
追跡リンクの判定には、パスやクエリを含むURLを一時的に扱う必要があります。しかし、レポートに必要なのは通常、追跡元のoriginまたは正規化したドメインです。そこで、保存前に情報を小さくします。
const persisted = {
domain: parsed.hostname,
url: `${parsed.origin}/`,
type: 'link' as const,
}
これにより、読み取り可能なパス、クエリ、フラグメント、デコード後のリンク先をイベント履歴へ残さずに集計できます。表示のために必要な情報量と、検出時に一時的に必要な情報量を同じにしない設計です。
送信者の集計も、メッセージ本文や件名を保存しなくても、送信者ドメインと最小限の帰属情報だけで作れます。ただし、この「ローカル保存」は「データを処理していない」という意味ではありません。ローカルで扱う情報も、何を読むか、どこに残すか、どの期間で削除するかを明示すべき対象です。
前期間比は分母がないときに作らない
「前期間比 +68%」も、単純そうで境界条件があります。比較対象の前期間にイベントが0件なら、通常の増減率は分母を持ちません。
実装では次のように扱えます。
if (previousRows.length > 0) {
change = Math.round(
((currentRows.length - previousRows.length) / previousRows.length) * 100
)
} else if (currentRows.length === 0) {
change = 0
} else {
change = undefined
}
比較不能を0%や無限大に見せず、指標自体を出さない選択肢を持つ方が、ダッシュボードの見た目より意味の正確さを守れます。
削除と集計表は同じトランザクションで揃える
期間制限で古いイベントを削除しても、別の送信者集計表に古い件数が残れば、同じ画面の数字が矛盾します。そこで、古いイベントの削除と、残ったイベントからの送信者統計再構築を同じIndexedDBトランザクションで行います。
これは性能だけの話ではありません。ユーザーが履歴を削除したのに、要約値だけ残る状態を避けるためのプライバシー境界でもあります。
実装時のチェックリスト
レポート指標を追加するときは、次の質問を先に答えるようにしています。
- 1行のイベントは、観測・推論・ブロック成功のどれを表すか。
- 集計はどの期間、どのクライアント、どのイベント種別を含むか。
- 「ユニーク」は人物、アドレス、ドメインのどれか。
- UIのラベルから、クリック履歴や完全阻止などの強い意味が推測されないか。
- 元URLや本文のような、集計に不要な情報を保存していないか。
- 分母0、重複イベント、履歴削除後の再集計をテストしたか。
- デモ画面なら、実データではないことを画像説明と本文で明示したか。
まとめ
プライバシー系のダッシュボードでは、数字を大きく見せるより、数字がどの証拠から作られたかを小さく保つ方が難しいものです。
- 検出イベントを、ブロック成功やクリックへ読み替えない
- 必要な期間の行から指標を再計算する
- 保存前にURLをorigin/domainへ最小化する
- 比較不能な比率を無理に表示しない
- 削除と要約の整合性をトランザクションで守る
この境界を先に決めておくと、あとからグラフやフィルターを増やしても、UIの主張が実装の証拠を追い越しにくくなります。
参考:Mailshade 1.0.6のリリース対応ソースとSHA-256は、公開ソースページから確認できます。
私はMailshadeを開発しているDanila Pryadkoです。この記事はAIの補助を使って下書きし、説明した挙動・型・境界条件を公開版1.0.6の実装とテスト資料で確認しました。

