目次

  1. はじめに
  2. 抱えていた課題
  3. 解決策: スキーマ駆動開発の導入
  4. カスタムプラグインの実装
  5. 運用のフロー
  6. 導入してみて
  7. まとめ

はじめに

株式会社 WinTicket でWebエンジニアをしている原島(@1keiuu)です。
Web開発を主に担当しつつ兼任でデータマネジメントチームにも在籍しています。

WINTICKETはサービス開始から6年以上が経ちますが、データマネジメントチームは比較的新しく少人数のチームです。
専任で担当できるエンジニアがいない期間もあり、事業の成長に対してデータマネジメントに関する課題がいくつかありました。
本記事ではクライアントエンジニアとしてログ実装に関して抱えていた課題とその解決策を紹介します。

抱えていた課題

WINTICKETではWeb、App(ネイティブアプリ)、Serverの3つのプラットフォームから、クリックや投票といったユーザーの行動ログを社内のデータ収集基盤へ送信しています。
この行動ログにおいて以下の3つの課題がありました。

  1. データ収集基盤が期待するデータ形式とログ実装の齟齬
  2. 分析に関する知識の属人化
  3. ログ仕様の変更に伴うコミュニケーションコスト

順番に紹介していきます。

1. データ収集基盤が期待するデータ形式とログ実装の齟齬

行動ログは社内のデータ収集基盤へ送信された後にBigQueryへ同期されます。

ただし各プラットフォームのリポジトリで型定義や送信ロジックを直接定義していたため、以下のような実装ミスが発生する事もありました。

  • データ収集基盤が期待しているスキーマと実際に送られる型やプロパティが一致しない
  • WebとAppで送るイベントの構造は同じだが、実際に送られる値がプラットフォーム間でずれている

2. 分析に関する知識の属人化

以下の記事でも触れられているように、これまで分析に関する知識が事業部内で属人化している状態でした。

特に行動ログに関しては種類が多く、今までもGoogleのスプレッドシートで管理している仕様書はあったのですが「どこにどのような行動ログの情報があるのか」が確認しづらい状況でした。
また編集/レビューのプロセスも確立されておらず仕様書としての信頼度も低い状態でした。

3. ログ仕様の変更に伴うコミュニケーションコスト

既存のログに変更が入った際には、データマネジメントチームから各プラットフォームのエンジニアへ変更内容を伝える必要があります。
2番目の課題の通り、スプレッドシートでの仕様書運用だと変更差分が分かりづらく、結局Slackや口頭でのコミュニケーションが発生している状態でした。

解決策: スキーマ駆動開発の導入

これらの課題を解決するために、行動ログのスキーマ(=構造化された単一の情報源)を定義し、それを基に「各プラットフォーム向けの型定義」と「職種問わずに閲覧しやすいドキュメント」を自動生成するシステムを構築しました。

Protocol Buffersによるスキーマ管理

スキーマの管理形式は、JSONやYAML、TypeSpecを候補として検討しました。
最終的にIDLとして広く利用されており、社内資産を活用できるという点でProtocol Buffersを採用しました。

Protocol BuffersはGoogleが開発しているインターフェース記述言語 (IDL)です。
WINTICKETでは1つの行動ログに対して1ファイルの.protoファイルを定義しています。

例えばユーザーがボタンなどのモジュールをクリックした際に発火するログは以下のように定義されています。

click_module.proto

syntax = "proto3";

package schema;

import "shared/options.proto";

message ClickModule {
  option (options.message_meta) = {
    description: "モジュールのクリックログ\nボタンの押下時などに発火します"
    platform: [
      PLATFORM_APP,
      PLATFORM_WEB
    ]
    release_date: "2025-12-25"
  };
  optional string module_name = 1 [
    json_name = "module_name",
    (options.field_description) = "モジュールの識別子",
    (options.allowed_value) = {
      value: "pay_button"
      description: "支払いボタン"
    },
    (options.allowed_value) = {
      value: "bet_button"
      description: "投票ボタン"
    }
  ];
  optional string module_label = 2 [
    json_name = "module_label",
    (options.field_description) = "モジュールのラベル名",
    (options.field_optional) = true
  ];
}

基本的なprotoにおけるスキーマ定義に加え、options.*で指定されているプロパティはカスタムオプションとして独自に定義したものです。
こちらは後ほど紹介します。

システムの構成

全体の構成はこのようになっています。

Protocol Buffersベースのシステムアーキテクチャ図。.protoファイルからBufを経由し、buf-gen-codeでTypeScript、Dart、Goのコードを生成し、buf-gen-doc-schemaでドキュメントサイトを生成する流れを示すフローチャート

Bufを使って.protoファイルから各プラットフォーム向けのアプリケーションコードとドキュメント用のTypeScriptファイルを生成しています。

BufとはBuf Technologies, Inc.によって開発されているProtocol Buffers用のツールチェーンです。
LinterやFormatter、後方互換性のチェック、設定ファイルによる依存管理等を標準で備えており、protocと比べてより安全なprotobufの活用ができるため採用しました。
またprotocと同様にカスタムプラグインを実装する事も可能です。

今回紹介するコード生成においてはアプリケーションコード用(buf-gen-code)とドキュメントサイト用(buf-gen-doc-schema)で2つのカスタムプラグインを作成してBufから呼び出しています。

カスタムプラグインの実装

続いて実際にコードを生成しているカスタムプラグインの実装についてご紹介します。
詳細な実装まで知る必要がなければその他のプラグインまで読み飛ばしていただいて構いません。

以前からWINTICKETのサーバーチームではProtocol
Buffersのカスタムプラグインを実装しており、そちらを参考にGo言語で実装しました。

プラグインの基本構造

ディレクトリ構造としては以下のようになります。

proto/
├── shared/
│   └── options.proto      # カスタムオプションの定義
├── events/                # 各イベントスキーマの定義
│    └── click_module.proto
│    └── view_module.proto
│    └── ...
plugins/
├── buf-gen-code/
│  ├── internal/
│  │   ├── generate/
│  │   │   ├── generator.go       # コード生成ロジック
│  │   │   └── extensions.go      # カスタムオプションのExtension定義
│  │   ├── templates/
│  │   │   └── {platform}_template.go # Go text/templateによる出力テンプレート
│  │   ├── format/
│  │   │   └── format.go          # 文字列フォーマットユーティリティ
│  └── {platform}/
│      └── {platform}.go # プラットフォーム別のコード生成
└── buf-gen-doc-schema/
    ├── internal/
    └── main.go # プラグインのエントリーポイント

プラグインの実装について詳細に紹介していきます。

アプリケーション向けのコード生成

エントリーファイル(main.go)の大まかな流れは以下のようになります

  1. protoファイルからプラットフォーム固有のMessage構造体を作成
  2. メッセージをプラットフォームごとに分類
  3. 各プラットフォーム毎のコード生成

1. protoファイルからプラットフォーム固有のMessage構造体を作成

protogen.OptionsRunメソッドで渡されるコールバック関数内にプラグインの処理を記述できます。
Bufはbuf generateコマンドを実行する際に、buf.gen.yamlに設定されたプラグインを順番に呼び出します。
buf.gen.yamlではプラグインが走査対象とすべきパスを指定でき、対象となったファイルはコールバック関数に渡されるplugin.Filesから参照できます。

func main() {
    opt := protogen.Options{}
    opt.Run(func(plugin *protogen.Plugin) error {
        for _, f := range plugin.Files {
            for _, m := range f.Messages {
                // カスタムオプションを解析してMessage構造体を作成
                message := processMessage(m)
            }
        }
        return nil
    })
}

各ファイルのMessages([]protogen.Message)をイテレートしてprocessMessage関数でカスタムオプションを解析し、プラットフォーム共通で扱い易いMessage構造体へ変換します。

type Message struct {
    Name                     string // 行動ログ名 (例: ClickModule)
    SnakeName                string // ログ名のスネークケース (例: click_module)
    Description              string // 説明
    Platform                 []Platform // プラットフォーム名 (web, app, server)
    Fields                   []Field // フィールドの配列
}

processMessage 関数内では上記のMessageと同じように各フィールドに関してもField構造体へ変換します。

type Field struct {
    Name          string // フィールド名 (例: module_name)
    Type          string // 型 (例: string)
    Description   string // 説明
    Optional      bool // オプショナルかどうか
    IsList        bool // 配列形式かどうか
    AllowedValues []AllowedValue // 許可される値
}

例えばAllowedValuesはprotoファイルでoptions.allowed_valueとして指定したオプションの配列です。
入りうる値が限定的なフィールドに設定し、クライアント側のログ送信部分で静的解析やバリデーションに使います。

2. メッセージをプラットフォームごとに分類

protoで定義された行動ログはプラットフォーム間共通で送るものもあります。
例えば[Protocol
Buffersによるスキーマ管理](#schema-management-with-protocol-buffers)で紹介したclick_moduleログはWebとApp間で共通のスキーマを使って送っています。
そのためprotoファイルのディレクトリはプラットフォーム毎では分けず、カスタムオプションで指定されたoptions.platformで分類しています。

実装としてはプラットフォームをkey、Messageの配列をvalueとしたmap(messagesByPlatform)を作成します。
processMessage関数から帰ってきたMessage構造体はカスタムオプションの情報が付与されている為、message.Platformの値に応じてmessagesByPlatformの配列へappendしていきます。

messagesByPlatform := map[Platform][]Message{}

for _, m := range f.Messages {
    message := processMessage(m)
    for _, platform := range message.Platform {
        messagesByPlatform[platform] = append(
            messagesByPlatform[platform], message,
        )
    }
}

3. 各プラットフォーム毎のコード生成

振り分けたメッセージをもとに、各プラットフォーム向けの生成関数を呼び出します。

if messages := messagesByPlatform[PLATFORM_WEB]; len(messages) > 0 {
    generateWebCode(plugin, messages)
    ...
}
if messages := messagesByPlatform[PLATFORM_APP]; len(messages) > 0 {
    generateAppCode(plugin, messages)
    ...
}
if messages := messagesByPlatform[PLATFORM_SERVER]; len(messages) > 0 {
    generateServerCode(plugin, msgs)
    ...
}

各プラットフォーム向けの生成処理では、まず共通のMessage/Field構造体をプラットフォーム固有のMessage/Field型に変換します。コード生成に必要な情報が各言語によって異なるためです。

func generateWeb(plugin *protogen.Plugin, messages []Message) error {
    for _, m := range messages {
      // 共通のMessage構造体をプラットフォーム固有のMessage構造体に変換
        webMessage := convertToWebMessage(m)
        ...
    }
    return nil
}

// protoのメッセージをWeb用構造体に変換
func convertToWebMessage(message Message) WebMessage {
    webMessage := WebMessage{
        Name:        message.Name,
        Description: message.Description,
        Fields:      []WebField{},
    }

    for _, field := range message.Fields {
        webField := WebField{
            Name:        field.Name,
            Type:        convertToWebType(field.Type),
            Description: field.Description,
            Optional:    field.Optional,
        }
        webMessage.Fields = append(webMessage.Fields, webField)
    }

    return webMessage
}

フィールドの型名は生成する言語によって表記が変わるため、protoでの型名を各言語の型名に変換する必要があります。

// protoの型をTypeScriptの型に変換
func convertToWebType(protoType string) string {
    switch protoType {
    case "int64", "double":
        return "number"
    case "bool":
        return "boolean"
    default:
        return "string"
    }
}

各言語の型システムに合わせてProtocol Buffersの型を変換しています。

Protocol Buffers TypeScript Dart Go
int64 number int int64
double number double float64
bool boolean bool bool
string string String string

プラットフォーム固有のMessage構造体を生成したら、Goのtext/templateパッケージを使用してテンプレートベースでコードファイルの内容(文字列)を生成し、ファイルへ出力します。

func generateWeb(plugin *protogen.Plugin, messages []Message) error {
    for _, m := range messages {
      // 共通のMessage構造体をプラットフォーム固有のMessage構造体に変換
        webMessage := convertToWebMessage(m)

        // Goのtext/templateを使用してテンプレートベースでコードファイルの内容を生成
        templateStr := generateFileTemplateStr(webMessage, webCodeTemplate)

        // ファイル出力
        outFile := plugin.NewGeneratedFile(
            fmt.Sprintf("/web/%s.ts", m.SnakeName),
            m.GoImportPath,
        )
        outFile.Write([]byte(templateStr))
    }
    return nil
}

実装のポイント

1. カスタムオプションの活用

生成するコードやドキュメントに含めたい追加情報はProtocol Buffersのカスタムオプションで定義しています。
カスタムオプションを使うことでメッセージ・フィールドなどの要素にユーザーが自由にメタデータを与える事ができます。

カスタムオプションはイベント共通のprotoファイルに定義しています。

message MessageMeta {
  string description = 1;
  repeated Platform platform = 2;
  string release_date = 3;
}

message AllowedValue {
  string value = 1;
  string description = 2;
}

extend google.protobuf.MessageOptions {
  MessageMeta message_meta = 50001;
}

extend google.protobuf.FieldOptions {
  repeated AllowedValue allowed_value = 50002;
}

proto/shared/options.protoに変更がある度にprotoc-gen-goを用いてgoのコードへ変換しておく必要があります。
プラグイン実行時のprocessMessage関数内(参照) にてカスタムオプションの内容をMessage構造体へ追加する以下のような処理を記述しています。

import (
  ...
  "github.com/golang/protobuf/protoc-gen-go/descriptor"
  ...
)
var EMessageMeta = &proto.ExtensionDesc{
    ExtendedType:  (*descriptor.MessageOptions)(nil),
    ExtensionType: (*shared.MessageMeta)(nil), // protoc-gen-goで生成された型
    Field:         50001,
    Name:          "options.message_meta",
    Tag:           "bytes,50001,opt,name=message_meta",
    Filename:      "shared/options.proto",
}

func processMessageOptions(message *protogen.Message, m *Message) error {
    messageOptions, ok := message.Desc.Options().(*descriptor.MessageOptions)
    if messageOptions == nil || !ok {
        return nil
    }
    extensionVal, _ := proto.GetExtension(messageOptions, EMessageMeta)
    if extensionVal == nil {
        return nil
    }
    eventMeta, ok := extensionVal.(*shared.MessageMeta) // protoc-gen-goで生成された型
    if !ok {
        return errors.ErrFailedConvertMessageMeta
    }

    m.Description = eventMeta.Description
  // 他のプロパティも同様に追加...
    return nil
}
2. 生成されたコードの配布

アプリケーション向けのコードはprotoファイルを管理しているリポジトリ内で生成し、各リポジトリが参照できる形で配布しています。

例. generated/web/click_module.ts (Webリポジトリ向け)

export const ClickModuleModuleNameValues = {
  PAY_BUTTON: "pay_button",
  BET_BUTTON: "bet_button",
} as const;

export type ClickModuleModuleName = (typeof ClickModuleModuleNameValues)[keyof typeof ClickModuleModuleNameValues];

/**
モジュールのクリックログ
ボタンの押下時などに発火します
- https://${ドキュメントサイトのドメイン}/events/click_module
*/
export type ClickModulePayload = {
  event: "click_module";
  /**
    モジュールの識別子
  */
  module_name: ClickModuleModuleName; // → 'pay_button' | 'bet_button'のUNION型になる
  /**
    モジュールのラベル名
  */
  module_label?: string;
};

生成されたTypeScriptコードは社内利用に閉じたGitHub Packagesに公開し、Webのアプリケーションリポジトリから参照するようにしています。
1. protoファイルからプラットフォーム固有のMessage構造体を作成 で紹介したように、ログ送信関数の引数を生成した型(例で言うClickModulePayload)に縛る事で、スキーマ違反をしている値の送信を開発段階で防ぐ事が出来ます。

例はWeb向けのコードですが、App/Server向けのコードに関してもそれぞれのニーズに合わせて生成しています。
Appは生成したDartのコードをRepository Dispatch経由でアプリケーションリポジトリに配布、Serverは生成したGoのコードをリポジトリ間で直接参照するようにしています。

ドキュメントサイト向けのコード生成

ドキュメントサイトはvikeを利用してviteで構築したReactアプリケーションを静的サイトとしてホスティングしています。

buf-gen-doc-schemaプラグインでは.protoファイルから以下のようなTypeScriptファイルを作成します。

export const schema: Schema = {
  events: [
    {
      meta: {
        name: "click_module",
        description: "モジュールのクリックログ\nボタンの押下時などに発火します",
        platforms: ["app", "web"],
        category: "user_action",
        releaseDate: "2025-11-25",
      },
      properties: [
        {
          name: "module_name",
          type: "string",
          description: "モジュールの識別子",
          isList: false,
          isOptional: false,
          properties: [],
          values: [],
        },
        {
          name: "module_label",
          type: "string",
          description: "モジュールのラベル名",
          isList: false,
          isOptional: true,
          properties: [],
          values: [],
        },
      ],
    },
    ...その他のイベント,
  ],
};

protoの変更がmainブランチにマージされた段階でリリースのGitHub Actionsが実行され、社内の人であれば誰でもアクセス出来るドキュメントページが公開されます。
こちらの実装詳細は割愛しますが、前述したbuf-gen-codeプラグインと基本構造は同じかつプラットフォーム毎の考慮がないため比較的シンプルな実装になっています。

実際にこのようなページが社内で公開されています。
WINTICKETデータポータルのclick_moduleテーブルのスキーマ画面。module_nameとmodule_labelの2つのstringプロパティを表示し、Dev環境、Stg環境、Prd環境の切り替えタブとJSONフォーマットの例を含むドキュメントページ

その他のプラグイン

本題とは少しずれますが、WINTICKETではデータ変換パイプラインにDataformを利用しており、そのコード(sqlx)を生成するプラグインも作成しています。

また、データの品質保証としてはDataplexを利用しており、「前日送られたあるイベントの10%をサンプリングし、protoで定義したallowed_value以外の値が送信されていないかをチェックする」などをしています。
Dataplexの品質チェックはTerraformでコード管理しているため、これも同じようにコード生成するようにしています。

行動ログ以外にもマスタデータのスキーマ管理も行っており、こちらもコード生成まで出来るようにしています。 詳しくはWINTICKETにおけるAI-Readyなデータ分析基盤の構築の「Protocol Buffers によるスキーマ管理」の章をご覧ください。

これら各工程のコード生成を1つのスキーマから行うことで、より安全かつ一貫性のあるデータ基盤の構築が実現できました。

運用のフロー

施策の開発がスタートすると、まずデータエンジニアがスキーマ(protoファイル)を定義してリポジトリへPullRequestを作成します。

スキーマやログ仕様に関するレビューが完了し、mainブランチへマージするとGitHub Actions上で以下の処理が走ります。

  • ドキュメントサイトへのデプロイ
  • 各アプリケーション向けのコード生成/配布
  • 社内データ収集基盤へのデプロイ

各種リソースが反映された旨を共有し、アプリケーションエンジニアは生成された変更を取り込んでログの実装を進めていきます。

導入してみて

運用のフローで紹介したように、スキーマ定義を起点としてドキュメントの作成からコード生成まで一気通貫で行えるようになり、抱えていた課題は概ね解決できたと思っています。
プラットフォーム間を跨いだ実装差異のリスクは極力小さくなり、今まで発生していた職種間のコミュニケーションコストも削減する事も出来ました。
ドキュメントサイトに関しても旧来のスプレッドシートより見やすく検索性も優れており、分析者、実装者問わず簡単にログの仕様を確認できるようになりました。

また、WINTICKETではDevinなどのAIエージェントによる分析にも力を入れています。
副次的な効果にはなりますが、今回のように構造化された単一の情報源を定義する事で、今後はAIエージェントが学習するデータとしての信頼性を高める事ができると考えています。

まとめ

本記事ではWINTICKETにおけるProtocol Buffersを活用したデータ基盤のスキーマ駆動開発についてご紹介しました。

生成AIの発達でハードルは下がりましたが、それでもデータエンジニアにとってアプリケーション側のログ送信実装を把握するにはかなりの労力がかかります。
一方でクライアント/サーバーサイドエンジニアにとって行動ログの内容や用途についてあまり意識する機会も少ないのではないかと思います。
ユーザーの行動ログは発火からデータ収集、分析、活用まで様々な領域を跨いだ長いプロセスで成り立っています。
だからこそ職種ごとに個別で解決を図るのではなく、各領域の抱えている課題を理解し統合的に対処することが強固で柔軟なデータ基盤の構築につながると感じました。

WINTICKETでは、データエンジニアを積極採用中です。データ分析基盤の構築や AI 活用に興味がある方は、ぜひカジュアル面談でお話ししましょう。