📊

アナリティクス機能開発で学んだデータ設計と実践

に公開

はじめに

この記事は TimeTree Advent Calendar 2025 の 18 日目の記事です。

TimeTree の公開カレンダーの開発に携わっている Isco と申します。
今回は、公開カレンダーオーナー様向けにリリースしたアナリティクス機能について、システム構成とデータ設計の工夫を中心に紹介します。
※以下、アナリティクスの画面になります。

公開カレンダーとは?

公開カレンダーは、イベント情報を誰でも見られる形で一般の方に発信できるカレンダーのことをいいます。そのカレンダーを運営するオーナーが予定を登録し、興味のあるユーザーはそのカレンダーをフォローして、自分のカレンダーと並べて最新スケジュールを確認できます。

https://timetreeapp.com/intl/ja/public-calendar

アナリティクス機能について

公開カレンダーを運営していると、「実際にどれくらいの人が見てくれているんだろう?」という疑問が出てきます。TimeTree のアナリティクス機能は、そんなオーナーの疑問に答えるために開発しました。

画面では主に以下のデータを確認できます。

フォロワー数と属性

円グラフと横棒グラフで、現在のフォロワー数を確認できます。年齢層や性別ごとの内訳も表示されるので、「意外と若い世代にフォローされているな」といった発見があります。

フォロワー数の推移

折れ線グラフで日別のフォロワー増減を追えます。「このイベントを投稿したら急にフォロワーが増えた」といったタイミングが一目でわかるので、どんな投稿が反響を呼んだか分析できます。

インプレッション数の推移

カレンダーやイベントがどれだけ表示されたかを日別で確認できます。フォロワー数だけでなく、実際の閲覧数を見ることで、カレンダーの露出度を把握できます。

イベント別詳細

各予定ごとにインプレッション数や反応を確認できます。人気のあるイベントの傾向を掴むことで、次にどんな予定を投稿すればいいか考えるヒントになります。

ウィジェット購入者数

公開カレンダーのウィジェット(有料機能)を購入してくれたユーザー数と、その推移を確認できます。収益化しているオーナーにとっては重要な指標です。

システム構成図

TimeTree の Web 版では、以下の技術スタックを採用しております。

Web Frontend(Cloud Run)

ユーザーがアクセスする公開カレンダーアナリティクスの画面になります。
React を使用しています(※ Next.js は使用していません)

Backend API(Cloud Run)

Rails で構築された API サーバーです。
認証・認可処理を行い、Cloud Spanner、Memorystore、BigQuery からデータを取得してフロントエンドへ返却します。

Sidekiq(Cloud Run)

バックグラウンドジョブの実行基盤です。
sidekiq-scheduler を利用して定期ジョブの実行環境としても利用しています。

日次統計作成の流れ

  1. sidekiq-scheduler によって日次で定期実行される
  2. 定期実行処理で統計情報作成のバックグラウンドジョブを Sidekiq にエンキューする
  3. Sidekiq のバックグラウンドジョブで統計情報を作成する

Cloud Spanner

データベースです。
公開カレンダー情報、フォロワー情報、日次統計レコードを格納します。
一方で、PV 数、URL タップ数は BigQuery に格納しています。
理由:大量のログデータ(毎回のイベント閲覧・URL クリック)が発生するため、Spanner よりも低コストで保存・集計できる BigQuery が最適だからです。

BigQuery

ログデータの保存と分析を行います。
公開カレンダーの閲覧ログを保存し、日次集計データを提供します。

Memorystore(Redis)

キャッシュ層です。
直近 35 日分のインプレッション数、フォロワー数、ウィジェット購入者数をキャッシュし、API 応答を高速化します。

その他

上記のシステム構成図で省略している部分の補足です。

設計上の工夫と課題

当初は全てのカレンダーを対象にアナリティクス機能を提供しようとしていました。しかし、アクティブなものもあれば非アクティブなものもあります。全公開カレンダーを対象にすると、日次バッチで作成すべき統計レコードが桁違いに増え、1 日 1 回の更新枠に収まりにくくなります。統計作成ジョブの処理が長時間化すると優先度の高いバックグラウンド処理の実行を妨げ、システム全体のスループット低下を招くリスクがあります。更にイベント情報は、性質上データ量が他の項目よりも更に増えます。処理が翌日に持ち越されると、Sidekiq ワーカーのキャパシティを圧迫し続けることになります。公開カレンダーのフォロワー数やインプレッション数は「カレンダー単位」で集計されますが、イベント情報(PV、URL タップ数)は「イベント単位」で記録されます。少し極端かもしれませんが以下のようなデータ量の違い出てきます。

  • カレンダー統計...1 カレンダー × 1 日 = 1 レコード
  • イベント統計...1 カレンダー × 平均〇〇イベント × 1 日 = 数十〜数百レコード

したがって、イベント情報については、BigQuery に保存し、集計しています。

本当に全ての公開カレンダーを対象にする必要性を考える

アナリティクス機能は、カレンダー運営者がデータを活用して改善を行うための機能です。しかし、フォロワーが少数のカレンダーでは統計データを利用する意味が薄いので運営者にとっての実用性も低く使用されない可能性が高いです。そこで、フォロワー数 100 名以上の公開カレンダーのみを対象としてオプトインにしました。 こうすることで上記の問題が緩和されます。

  • 処理負荷の大幅削減
    • 統計作成の対象カレンダー数が大幅に減少し、日次バッチの処理時間が大幅に短縮される
  • Sidekiq ワーカーへの影響回避
    • キューの処理時間が短縮され、他の重要なバックグラウンド処理への影響を最小化できる
  • リソースの効率的配分
    • リソースを実際に利用されるアクティブなカレンダーに集中できる

上記の要件にしたことで、当初考えていたテーブルの設計も変更をしました。

JSON 集約方式(当初考えていた案)

daily_statisticsカラムの構造例

{
  "2025-08-01": {
    "subscription_count": 123,
    "impression_count": 12345
  },
  "2025-08-02": { ... },
  ...
  "2025-08-31": { ... }
}

良い点

  • レコード数の削減
    • 全公開カレンダー × 12 ヶ月 = 年間レコード数を大幅削減
    • 例: 10,000 カレンダー × 365 日 = 3,650,000 レコード → 10,000 カレンダー × 12 ヶ月 = 120,000 レコード(約 1/30)
  • ストレージ効率...テーブルサイズが小さくなる
  • 月次集計が高速...1 レコード読み取りで 1 ヶ月分のデータを取得

問題点

  • JSON クエリの複雑性
  • 更新コストの増加
    • 1 日分のデータ追加でも、月全体の JSON を読み込み → 更新 → 書き込みが必要
    • JSON サイズが大きくなるほど更新コストが増加
  • 範囲検索のしづらさ
    • 「8/15〜9/15」のような月跨ぎクエリで 2 レコードを読み、JSON をパース・結合が必要
    • インデックスが効きにくい
  • データ型の損失
    • JSON 内の数値はパース後に型変換が必要
    • NULL 処理が複雑になる

日次レコード方式(採用案)

良い点

  • シンプルなクエリ
    SELECT * FROM public_calendar_statistics
    WHERE public_calendar_id = ?
      AND aggregation_start_at BETWEEN '2025-08-15' AND '2025-09-15'
    
    • 標準 SQL で効率的にクエリ可能
    • インデックスが効果的に機能
  • 並行更新の安全性
    • 各日のレコードが独立しているため、ロック競合が発生しにくい
    • 複数バッチが並行実行可能
  • データ整合性の向上
    • 各カラムに適切なデータ型を設定
    • NOT NULL 制約、CHECK 制約が使える
    • モデルレベルでのバリデーションが容易
  • 柔軟な集計
    • SUM, AVG, MAX 等の SQL 集約関数が直接使える
    • 週次、月次、任意期間の集計が容易
  • デバッグとメンテナンス性
    • 各日のデータが独立したレコードで確認しやすい
    • 異常値の特定や修正が容易

問題点

  • レコード数の増加
    • ただし、フォロワー 100 名以上に絞ることで現実的な数に抑制
    • 例: 1,000 カレンダー × 365 日 = 365,000 レコード/年(管理可能な範囲)

フォロワー数が 100 名以上を対象にすることで上記の問題点をある程度緩和できると思い、日次レコード方式を採用しました。

運用をしている中で起きたスロークエリ問題と対応

アナリティクス機能のリリース後、統計用のレコードを作成する Job の部分で、スロークエリが発生していることが判明しました。このジョブは日次で実行され、アナリティクス機能が有効な全公開カレンダーの統計データを作成する処理です。

Datadog APM Trace を確認すると以下のことがわかりました。

  • 1 ジョブあたりの負荷が高く、対象カレンダー数が増えるにつれてパフォーマンスが線形に悪化
  • Cloud Spanner への大量のクエリがボトルネックとなっていた

対応した内容

1. Stale Read Transaction の適用

https://docs.cloud.google.com/spanner/docs/transactions?hl=ja#read-only_transactions

統計作成は前日のデータを対象とするため、厳密な最新性は不要になります。

def subscriptions(public_calendars:, to:)
  ActiveRecord::Base.transaction(isolation: { staleness: STALENESS }) do
    # 15秒のstale readに変更
    # ...
  end
end

こうすることで Spanner への読み取り負荷を軽減しますが、対象カレンダー数が増えると 1 ジョブの負荷が増大する根本的な問題は残ったままです。

2. バッチサイズの最適化とキュー変更

全カレンダーを固定数(concurrency)で分割していたため、対象カレンダーが 1000 個の場合、1 ジョブあたり 1000 個を処理してしまっていました。 このままだとサービス成長とともに 1 ジョブの負荷が増大してしまいます。

- def initialize(from, to, concurrency: 1)
+ def initialize(from, to, max_item_size: 50)
   @from = from
   @to = to
-  @concurrency = concurrency
+  @max_item_size = max_item_size
 end

 def run
-  target_public_calendar_ids.in_groups(concurrency, false).each do |public_calendar_ids|
+  target_public_calendar_ids.each_slice(max_item_size) do |public_calendar_ids|
     PublicCalendarStatisticsCreateJob.perform_later(public_calendar_ids:, from:, to:)
   end
 end

変更したポイントとしては以下です。

  • in_groups(concurrency)で全体を N 個のグループに均等分割
    • 例: 300 カレンダー、concurrency=3 → 各ジョブ 100 個ずつ
    • カレンダー数が増えると 1 ジョブの負荷も増加
  • each_slice(max_item_size)で 固定サイズでバッチ分割
    • 例: 300 カレンダー、max_item_size=50 → 50 個ずつ 6 ジョブ
    • カレンダー数が増えてもジョブ数が増えるだけで、1 ジョブの負荷は一定

キューの優先度の変更

class PublicCalendarStatisticsCreateJob < ApplicationJob
- queue_as :default
+ queue_as :low

統計作成は日次バッチ処理で、即時性は不要だと判断しキューの優先度を下げました。
優先度を下げることで他の重要な処理への影響を最小化することにも繋がります。

上記の二つの対応をすることで、スロークエリが改善しました。

グラフライブラリの選定と分割ツールチップの実装

アナリティクスではリッチなグラフ表示が必要になるので、デザインやチームに合うチャートライブラリを選定しました。当時 Figma 公式の MCP がちょうど流行り始めたタイミングだったので、Figma MCP を利用してライブラリ選定を効率良く進めました。過程については省略しますが、recharts を選定しました。

https://recharts.github.io/

選定理由は色々とありますが、ざっくり以下です。

  • シンプルな API
  • 既存のメンバーの知見
    • チーム内のメンバーに使用経験があり、学習コストが低い
  • 依存が軽量で Tree-shaking 対応。既存の Frontend のスタックとも競合が少ない
  • コミュニティが活発で Issue/PR の放置が少なくメンテが継続している
  • API が JSX コンポーザブルで、Figma の構成を部品単位で再現しやすい
  • 独自ツールチップ分割などのカスタムも実装可能。段階導入の方針で運用コストを抑制
  • 主要チャート(折れ線、棒、積み上げ、エリア、ドーナツなど)と軸・凡例・グリッドの表現力が要件に十分
  • テーマやスタイルの上書きが容易

検討した他のライブラリ

ライブラリ メリット デメリット
Visx 低レベルなプリミティブで柔軟性が非常に高い。細かな表現調整が可能 低レベル API で自由度は高いが、開発期間とのトレードオフで採用を見送り
Apache ECharts 機能が非常に豊富。大規模データやインタラクションが強力 バンドルサイズやテーマ適用の多少のクセがある
Nivo コンポーネントが充実しており素早く組み立てられる。テーマ機能もあり 複雑なツールチップ分割や細部の制御で拡張コストがかかる可能性
Highcharts エンタープライズ実績、リッチな相互作用と多機能 ライセンスとバンドルサイズの観点で今回要件には過剰。React カスタマイズの自由度も相対的に低い

分割ツールチップの実装

また少し工夫した点としては、以下の分割ツールチップの部分です。

複数の指標(フォロワー数、インプレッション数など)を同時に表示する際、各データポイントの値を個別のツールチップで表示する分割ツールチップを実装しました。 大半のグラフ系のライブラリでは、この分割ツールチップの機能が提供されていません。仮説になりますが、このような理由があるのではないかなと思います。

  • 複数のデータポイントが同じ日に同じ値になった場合、ツールチップが重なってしまう
  • ツールチップの配置スペースが不足した場合の表示優先度の決定
  • 画面端でのツールチップのはみ出し処理
  • データ数が増えた場合のパフォーマンス劣化

そのため、デザインに沿ってカスタム実装する必要がありました。

実装アプローチ

Recharts の Tooltip コンポーネントを使用して、カスタムレンダリングを実装しました。

1. データ値からピクセル座標への変換

// 各ツールチップのY座標を計算
const tooltipPositions = payload.map((item: PayloadItem) => {
  // チャートの高さとデータの最小・最大値から座標を計算
  const originalY =
    chartHeight -
    ((item.value - minValue) / (maxValue - minValue)) * chartHeight +
    offset.top;

  return {
    ...item,
    originalY,
    adjustedY: originalY, // 初期値は元の位置
  };
});

2. 重複回避のロジック

// Y座標でソート(上から下へ)
tooltipPositions.sort((a, b) => a.originalY - b.originalY);

// 重複を避けるためにY座標を調整
for (let i = 1; i < tooltipPositions.length; i++) {
  const current = tooltipPositions[i];
  const previous = tooltipPositions[i - 1];
  const minDistance = TOOLTIP_HEIGHT + MIN_TOOLTIP_SPACING; // 35px

  if (current.adjustedY - previous.adjustedY < minDistance) {
    // 前のツールチップと近すぎる場合、下にずらす
    current.adjustedY = previous.adjustedY + minDistance;
  }
}

3. レンダリング

return (
  <>
    {tooltipPositions.map((item) => (
      <div
        key={item.dataKey}
        className={tooltipStyle}
        style={{
          top: item.adjustedY + TOOLTIP_VERTICAL_OFFSET,
          left: coordinate.x - TOOLTIP_HORIZONTAL_OFFSET,
          transform: 'translate(-100%, 0)', // 左側に表示
          color: item.color,
        }}
      >
        {annotationsMap[item.dataKey]}: {item.value.toLocaleString()}
      </div>
    ))}
  </>
);

おわりに

公開カレンダーのアナリティクス機能開発を通じて、普段の Web アプリケーション開発とは異なるデータ設計の重要性を実感しました。 カレンダー統計、イベントログ、リアルタイムデータなど、それぞれのデータ特性(ライフサイクル、データ量、どのようにアクセスされるか)を正しく理解することが不可欠でした。 現在は「データを見る」機能に重点を置いていますが、今後は「データを活用する」機能へ進化させたいと考えています。例えば、フォロワー獲得のための具体的な施策提案や、投稿タイミングのレコメンドなど、オーナーが実際のアクションにつなげられる機能を提供していきたいと思います。 アナリティクス機能などを提供する方の何かしらの参考になれば幸いです!

TimeTree Tech Blog

Discussion