TypeScriptの型でイベント計測のドリフトを防ぐシステムを図解。イベントがTYPE MAPやFUNNELを通り一貫性をもって流れる様子と、ドリフトを回避するパス、そして説明する人物が描かれている。データ収集の安定性を示唆。

はじめに

アナリティクスのイベント計測は、最初に基盤を作るときよりも「そこに新しいイベントを追加するとき」の方が事故が起きやすい作業です。

イベント名のタイポ、payloadの形が既存イベントと微妙に違う、発火のタイミングやしきい値が他の箇所とズレる、といった小さな不整合が積み重なると、後から集計する段階で「このイベント、実は条件が揃っていない」と気づくことになります。

あるプロジェクトで、すでにfail-closedなallowlist検証つきの計測基盤がある状態に、新しいファネルイベントを3種類追加する作業を行いました。「検索結果が何件表示されたか」「検索結果からどの項目がクリックされたか」「常設の外部導線がどこから押されたか」という3つです。基盤自体はすでにあるため、今回のポイントは新規イベントを既存の設計にどう乗せるか、そして発火条件を既存ロジックとどう揃えるかの2点でした。この記事では、そのときに使った設計パターンをまとめます。

こんな人におすすめ

  • 型付きのイベント計測基盤(trackEvent のようなラッパー)をすでに運用していて、新しいイベントを追加する機会がある方
  • 複数のコンポーネントにまたがる発火条件・しきい値を揃える必要に迫られたことがある方
  • クリックの文脈判定のために状態やフラグを増やしすぎてしまった経験がある方
  • allowlist方式のバリデーションで計測の安全性を担保したい方

型定義に1エントリ追加するだけで新イベントを増やせる設計にする

新しいイベントを増やすたびに呼び出し側のコードをあちこち書き換える必要があると、payloadの形が少しずつブレていきます。

そこで、イベント名ごとのpayload型を1つのマップ型にまとめておき、新しいイベントを追加するときはそこに1エントリ足すだけで済むようにしました。

export type AnalyticsEventPayloads = {
  // ...既存のイベント定義
  [ANALYTICS_EVENTS.VIEW_SEARCH_RESULTS]: {
    /** 検索クエリ本文ではなく件数のみを送る。0件と非0件を区別するための唯一の値 */
    result_count: number;
  };
  [ANALYTICS_EVENTS.SEARCH_RESULT_SELECTED]: {
    topic_id: OpaqueId;
  };
  [ANALYTICS_EVENTS.EXTERNAL_LINK_CLICK]: {
    link_location: ExternalLinkLocation;
  };
};

このマップ型を trackEvent のシグネチャに結びつけておけば、呼び出し側で trackEvent('view_search_results', { result_count: 12 }) のようにpayloadの形が間違っていた場合、実行前にコンパイルエラーとして検出できます。ランタイムのallowlist検証と型チェックの二重の防御になっているのがポイントです。

発火しきい値は既存ロジックと明示的に揃える

検索バー側にはすでに「3文字以上でsearchイベントを発火する」というしきい値がありました。新しく追加する「検索結果表示」イベントも、このしきい値に合わせて trimmed.length >= 3 を条件にしています。

しきい値がコンポーネントごとにバラバラだと、同じ「検索した」という行為でも、イベントが発火するタイミングがズレてしまい、後から集計したときに数字が噛み合わなくなります。

さらに、発火の判断を状態変数の読み取りに頼らず、非同期処理の戻り値で行うようにしました。

const loadResults = useCallback(async (search?: string | null): Promise<number | undefined> => {
  if (isFetchingRef.current) return undefined;
  isFetchingRef.current = true;

  try {
    const { items, totalCount: count } = await fetchItemsFromApi(null, search);
    setItems(items);
    setTotalCount(count);
    return count;
  } catch (err) {
    setError('読み込みに失敗しました。');
    return undefined;
  } finally {
    isFetchingRef.current = false;
  }
}, []);

const setSearchQuery = useCallback(
  (query: string) => {
    const trimmed = query.trim();
    loadResults(trimmed || undefined).then((count) => {
      // 既存のsearchイベントと同じ3文字以上のしきい値に揃える。
      // countがundefinedの場合は取得が失敗またはスキップされたため計測しない。
      if (trimmed.length >= 3 && count !== undefined) {
        trackEvent('view_search_results', { result_count: count });
      }
    });
  },
  [loadResults]
);

検索APIを呼ぶ関数の戻り値を Promise<number | undefined> にして、成功時は件数を、失敗・スキップ時は undefined を返すようにしています。呼び出し側は「フェッチが実際に成功した場合のみ計測する」を、状態変数を読みにいかずに素直に書けるようになりました。

状態を増やさずに文脈を判定し、設置場所は型で強制する

「検索結果表示中のクリックか、通常一覧からのクリックか」を判定する必要がありましたが、新しいフラグを追加するのではなく、すでに受け取っている searchQuery propの有無で判定しました。既存のpropを流用できる場合は、状態を増やさない方がテストも書きやすくなります。

もう1つ、同じリンクコンポーネントが複数箇所(サイドバーやモバイルヘッダーなど)に置かれるケースでは、設置場所を推測に頼らず、型で強制するようにしました。

interface ExternalLinkProps {
  /** 設置場所(事業コンバージョンの代理指標としてどの導線から遷移したかを区別する) */
  location: ExternalLinkLocation;
}

export function ExternalLink({ location }: ExternalLinkProps) {
  return (
    <a
      href={EXTERNAL_TOP_URL}
      target="_blank"
      rel="noopener noreferrer"
      onClick={() => trackEvent('external_link_click', { link_location: location })}
    >
      {/* リンク本体 */}
    </a>
  );
}

location をrequired propにして呼び出し元ごとに明示的に渡させ、許可値をunion型とallowlist配列の両方で縛っています。DOM構造や親コンポーネント名から設置場所を推測するより、呼び出し側に明示させておいた方が、新しい設置場所が増えたときに計測が漏れる事故を防ぎやすくなります。

ネガティブケースのテストも対で書く

「発火する」テストだけでなく、「検索表示中でなければ発火しない」「不正な件数や余分なフィールドを含むpayloadは拒否される」というネガティブケースも対で書くようにしました。

allowlist側の防御は、ポジティブケースのテストだけでは壊れたことに気づきにくい部分です。誰かが将来allowlistのロジックを触ったときに、ネガティブケースのテストが落ちることで初めて「防御が壊れた」と検知できます。

つまづきやすいポイント

  • 発火しきい値を1箇所ずつ手で揃える運用は、将来別の場所で同じ体験を実装するときに揃え忘れるリスクが残ります。しきい値そのものを共通定数に切り出しておく方が安全です
  • 文脈判定のためについフラグを増やしたくなりますが、既存のpropで代用できないか先に検討する価値があります
  • 設置場所のようなパラメータは、推測ロジックではなく呼び出し元に明示させる設計の方が、後から設置箇所が増えたときに計測漏れが起きにくくなります
  • allowlist検証のテストは、ポジティブケースだけでなくネガティブケースも用意しないと、防御が壊れたことに気づけません

まとめ

型付きのイベント計測基盤に新しいイベントを追加する作業は、単に「動けばいい」ではなく、既存の設計にどう乗せるかが後々の集計精度を左右します。

イベント名ごとのpayload型を1箇所にまとめておくこと、発火しきい値を既存ロジックと明示的に揃えること、状態を増やさずに既存のpropや戻り値で判断を代用すること、この3つを意識するだけで、新規イベント追加によるドリフトはかなり抑えられます。

同じような計測基盤の拡張に取り組む際の参考になれば幸いです。