📈

ログ実装を安全に! スプレッドシートからソースコードを自動生成する仕組み #TimeTreeアドカレ

2023/12/25に公開

これは株式会社TimeTree Advent Calendar 2023の25日目の記事です。

https://qiita.com/advent-calendar/2023/timetree

こんにちは。フロントエンドエンジニアの @fujikky です。

今回の記事で紹介する「ログ」とは、サービス改善を目的としてユーザーの行動をトラッキングする仕組みのことを指しています。これによりデータ駆動型の意思決定や効果的なサービス改善が可能となります。弊社で活用しているログ収集・解析ツールはGoogle Analytics (Firebase Analytics)やAmplitudeなどが挙げられます。

データDiv宣言

TimeTreeがログをいかに大事に考えているかの理由の一つとして、先日社内で発表されたデータDiv宣言について紹介します。

Whatよりも、Whyを。依頼よりも、チームとの協働を。
直感よりも、科学的な根拠を、重視していきます。

(中略)

施策の最後に分析を依頼されることが多いですが、残念ながらその時点ではできることが限られており、データアナリストが十分に能力を発揮できる状況にありません。

施策の最初から関わることで、事前分析による施策立案のアイディア出しやサポート、効果検証の計画、分析レポートの作成による深い学習を得ることができます。

全文は以下に公開されているのでぜひご覧ください。
https://twitter.com/TimeTreeInc_JP/status/1719599840970313810

このように、最近立ち上がったプロジェクトは初期からデータアナリストに参加してもらい、念入りにデータ解析の設計が行われます。そして正しくデータ解析を行うためには設計意図通りに正確にログの取得をすることが何よりも重要となってきます。

ログ実装あるある

ログ実装には様々な課題が潜んでいます。例えば、ログの実装漏れが発生すると、重要なユーザーアクションやサービス利用パターンが見逃され、適切な改善が行えません。ただ実のところログを追加する作業は通常開発タスクの一部に組み込まれており、要件通りに実装すると漏れることはあまりありません(まれにこの数字が出てこないんですけど、とDAの方から連絡をいただくことがありますが…)

よくあるのが、過去に使われていたログが消されずに残っている場合です。大きな問題にはなりにくいので放置されがちなのですが、コードを一見しても消していいのかどうかが分かりづらく、定期的な棚卸しの機会でもないと削除されることはありません。

他にも、複数のプラットフォームでのログイベント名やパラメーターの揺れが生じることもあります。これがデータ解析の際に不整合につながる場合もあり、可能な限り排除したい問題です。これらの問題は開発の時点では発見が困難で、分析の段階でようやく発見されることがありました。

ログ用のスプレッドシートからコードを生成

TimeTreeではログの実装をより効率的かつ誤りなく行うために、iOS、Android、Webなどの各プラットフォームで共通のログ定義のマスターとしてスプレッドシート上で定義しています。このスプレッドシートはエンジニアだけでなく、データアナリストやプロダクトマネージャーなど、様々な職種のメンバーも編集可能です。これにより、関係者全体がログの仕様を把握し、柔軟に対応できる状態が構築されています。

以前はこのスプレッドシートから手作業でログ実装を行なっていたのですが、ログの不整合を防いだり開発プロセスを効率的にするために、スプレッドシートからソースコードを生成する仕組みが作られました。

例えば、以下のスプレッドシートは、次のようなソースコードを生成します。


ログ定義をするスプレッドシート

生成されるコードの例
/**
 * 予定作成完了
 */
export const logCreateEvent = (properties: {
  /** どこから作成したか
menu=カレンダー上部, montly_date=マンスリーカレンダー上で日付を選択して作成, weekly_date=weeklyカレンダー上で日付を選択して作成 */
  readonly referer: "menu" | "monthly_date" | "weekly_date";
  /** 終日かどうか */
  readonly all_day: boolean;
  /** 旧暦かどうか */
  readonly lunar: boolean;
  /** アラート設定 */
  readonly reminders: boolean;
  /** 繰り返し設定 */
  readonly recurrences: boolean;
  /** 場所の設定 */
  readonly location: boolean;
  /** 参加者の数 */
  readonly attendees_count: number;
  /** URLの設定 */
  readonly url: boolean;
  /** メモの設定 */
  readonly note: boolean;
  /** チェックリストの設定 */
  readonly checklist: boolean;
  /** ファイル添付の設定(有料のみ) */
  readonly file: boolean;
}) => {
  logGAEvent("create_event", properties);
};

TimeTreeでは、iOS, Android, Webの各クライアントで静的型付け言語が使われているので、生成された型をそのままログ用のコードとして利用可能です。以下にメリットを挙げてみます。

  • 各イベントログが複雑なプロパティを持っている時に、関数の引数の型として生成されるため、入力ミスも防げる
  • プロパティが追加・削除されたときは、コード生成するタイミングでコンパイラが検知してくれるので、スプレッドシートとのコードが正しく同期される
  • イベントログが削除されたときはコードからも削除されるので、不要になったログが残り続けない
  • ログのイベント数で従量課金となるサービスは一部のログだけを落とすようにしていて、ログによって落とすサービスを内部的に切り替えやすい

仕組み

今回は実装をNode.jsで行っています。実はiOS, Androidにもほぼ同様の実装もあるのですが、そちらはRubyで書かれています。(社内用にライブラリ化もしたいのですが、今後の課題になっています)

  • 認証
    事前にGoogle OAuthに必要な client_id, client_secret を共有しておきます。google-auth-libraryを使って認証しますが、基本的にはCLIツールとして動作させたいので、認証フローのヘルパーとしてgoauth-cliを使っています。これはローカルにシンプルなHTTPサーバーを立て、認証コールバックを受けて認証トークンをアプリケーション側に返すという仕組みで動作しています。また、認証後はトークンをJSONファイルとしてローカル保持しておくことで何度も再認証をしなくても済みます。この仕組みにより、認証した本人の権限でスプレッドシートにアクセスすることが可能です。

    コード例
    import { GoauthCli } from "goauth-cli";
    import { type Credentials, OAuth2Client } from "google-auth-library";
    export const authorize = async () => {
      // ローカルのトークンファイルを読み込む(実態はJSON)
      const credentials = await loadCredentials();
      if (credentials) {
        const client = new OAuth2Client({
          clientId: OAUTH_CLIENT_ID,
          clientSecret: OAUTH_CLIENT_SECRET,
        });
        // トークンが更新された時にローカルのトークンを上書きする
        client.on("tokens", async (credentials) => {
          await saveCredentials(credentials);
        });
        client.credentials = credentials;
        return client;
      }
      // ローカルのトークンがなければgoauth-cliで認証フローを開始
      const auth = new GoauthCli(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, [
        "https://www.googleapis.com/auth/drive",
        "https://www.googleapis.com/auth/spreadsheets",
      ]);
      const client = (await auth.login()) as OAuth2Client;
      await saveCredentials(client.credentials);
      return client;
    };
    
  • スプレッドシートのデータ収集
    google-spreadsheet を使うことでGoogleスプレッドシートのデータをNode.jsから簡単に扱えます。スプレッドシートのURLの末尾の文字列をIDとして渡すことでスプレッドシートドキュメントが取得可能です。スプレッドシートの全行をループして、イベント名や各プロパティを専用の構造体に集めていく処理をします。

    コード例
    import type { OAuth2Client } from "google-auth-library";
    import { GoogleSpreadsheet } from "google-spreadsheet";
    import type { Event, Property, SpreadsheetRow, PropertyValue } from "./types";
    
    export const collectEvents = async (oauthClient: OAuth2Client) => {
      // スプレッドシートのロード
      const doc = new GoogleSpreadsheet(SHEET_ID, oauthClient);
      await doc.loadInfo();
      const sheet = doc.sheetsByTitle[SHEET_TITLE];
      // 2行目をヘッダーとすることで row.get("event_name") などヘッダー名でアクセスできるようにする
      await sheet.loadHeaderRow(2);
      // 1〜2行はヘッダーなのでスキップ
      const [, , ...rows] = await sheet.getRows<SpreadsheetRow>();
      return rows.reduce<readonly Event[]>((prev, row) => {
        const event = row.get("event_name")
          ? {
            category,
            desc: row.get("event_desc"),
            name: row.get("event_name"),
            properties: [],
          } : null;
          // プロパティがある場合は書き換える
          if (row.get("property")) {
            // イベント名が空白だった場合は、前回のイベントのプロパティを編集する
            const lastEvent = event ?? prev[prev.length - 1];
            lastEvent.properties.push({
              ...parsePropertyValues(row.get("property_values")),
              name: row.get("property"),
              desc: (row.get("property_short_desc")).trim(),
            });
          }
          return events: event ? [...prev, event] : prev;
        },
        [],
      );
    };
    

    各プロパティのパース処理は指定された構文によって生成される型が決定します。

    構文
    yes, no boolean
    "hoge", "fuga" string
    1, 2, 3 number
    hoge, fuga enum (string literal)
    {...} そのまま型として出力
    コード例
    const parsePropertyValues = (rawValues: string): PropertyValue => {
      // カンマ区切りで分割して、空白を取り除く
      const values = rawValues
        .split(",")
        .map((v) => v.trim())
        .filter((v) => !!v)
        .filter((v) => !/^\.+$/.test(v));
    
      // 値が ["yes", "no"] と一致したら boolean 型とする
      if (R.equals(values, ["yes", "no"])) {
        return { type: "boolean" };
      }
      // 値がダブルクォートに囲まれていたら string 型とする
      if (values.every((v) => /^".+"$/.test(v))) {
        return { type: "string" };
      }
      // 値がすべてが数字だったら number 型とする
      if (values.every((v) => !isNaN(Number(v)))) {
        return { type: "number" };
      }
      // `{...}` で囲まれていたらそのまま型として出力する
      const rawMatch = rawValues.match(/^\{(.+?)\}$/);
      if (rawMatch) {
        return { type: "raw", raw: rawMatch[1] ?? "" };
      }
      // それ以外は enum 型
      return { type: "enum", options: values };
    };
    
  • テンプレートをレンダリング
    テンプレートを ejs の形式で作っておき、集めたデータをテンプレートに渡してレンダリングします。コードのフォーマットはこのあとの工程で行うので、インデントなどは気にしないで書くことができます。

    テンプレートの例
    /**
     * This file was auto-generated.
     * Do not make direct changes to the file.
     */
    
    import { logGAEvent } from '~/modules/GoogleAnalytics'
    
    <%_ for (const event of events) { _%>
      /**
        * <%- event.desc %>
      */
      export const log<%- event.name.split(/[_-]/).map(  n => n[0].toUpperCase() + n.slice(1)).join("") %> =
    
      <%_ if (event.properties.length === 0) { _%>
        () => {
      <%_ } else { _%>
        (
          properties: {
            <%_ for (const property of event.properties) { _%>
              /** <%- property.desc %> */
              <%_ if (property.type === "boolean") { _%>
                readonly <%- property.name %>: boolean;
              <%_ } else if (property.type === "string") { _%>
                readonly <%- property.name %>: string;
              <%_ } else if (property.type === "number") { _%>
                readonly <%- property.name %>: number;
              <%_ } else if (property.type === "raw") { _%>
                readonly <%- property.name %>: <%- property.raw %>;
              <%_ } else { _%>
                readonly <%- property.name %>: <%- property.options.map(o => `"${o}"`).join(" | ") %>;
              <%_ } _%>
            <%_ } _%>
          }
        ) => {
      <%_ } %>
        logGAEvent(
          "<%- event.name -%>",
          <%_ if (event.properties.length !== 0) { _%>
            properties
          <%_ } _%>
        )
      }
    <%_ } _%>
    
  • コード整形
    サービス固有のコード規則に揃えるために ESLint の autofix と Prettier を実行します。実行結果をファイルに書き込めば完了です。

    コード例
    import path from "path";
    
    import { ESLint } from "eslint";
    import prettier from "prettier";
    
    const eslint = new ESLint({
      fix: true,
      cwd: path.resolve(__dirname, "../.."),
    });
    
    export const format = async (source: string) => {
      // eslint
      const eslintResult = await eslint.lintText(source);
      const autofixed = eslintResult[0]?.output ?? source;
    
      // prettier
      return await prettier.format(autofixed, { parser: "typescript" });
    };
    

生成されるコード

生成されたコードはこのようにコード補完もれるようになりますし、型が合わなければCIでも落ちます。

また、テストはこのように個別のログ用関数をモックすることでシンプルに検証可能です。

jest.mock("~/__generated__/logger", () => ({
  logCreateEvent: jest.fn(),
}));
const logCreateEventMock = logCreateEvent as jest.Mock;

test("予定作成処理", async () => {
  await createEvent();

  expect(apiMock).toHaveBeenCalledWith(...);

  expect(logCreateEventMock).toHaveBeenCalled({
    referer: "monthly_date",
    all_day: true,
    lunar,
    reminders: false,
    recurrences: false,
    location: false,
    attendees_count: 1,
    url: false,
    note: false,
    checklist: false,
    file: false,
  });
  expect(logCreateEventMock).toHaveBeenCalledTimes(1);
});

最後に

TimeTreeのアドベントカレンダー2023は本日で終了となります!今回は社内でも初めての試みだったのですが、11月の後半に結構タイトなスケジュールで募集したにも関わらず、多くのメンバーが参加してくれて本当に嬉しい限りです。

これを機にエンジニアからも積極的に情報発信をしていきたいと考えているので、ぜひよろしくお願いします!

TimeTreeの採用情報

TimeTreeのミッションに向かって一緒に挑戦してくれる仲間を探しています。TimeTreeで働くことに興味がある方はぜひ、Company Deck(会社紹介資料)や採用ページをご覧ください!

TimeTree Tech Blog

Discussion