ネイティブアプリのログをBigQueryで安定して運用する工夫
6月から stand.fm でエンジニアをやっている @sisisin です。
stand.fm ではログ基盤に Cloud Logging と BigQuery、ネイティブアプリ開発に React Native を利用しています。
今回は React Native アプリから取得したログを BigQuery に入れて運用するにあたって課題になったことと、運用の安定化のために行った工夫を紹介させていただきます。
BigQueryでログを見たいモチベーション
速い・SQLで見たいところだけ見られ、集計が出来る。これに尽きます。
Cloud Loggingはクエリも独自構文で表現力が限られており、見たいログのフィールドを見るのにもクリックなどの操作をする必要があり手間です。
BigQueryはSQLを利用して検索・集計が柔軟に出来る上にやたら速いので、ログの集計した件数から問題点を探すといった用途に向いています。
そのため、可能な限りログをBigQueryで見られるようにしていきたい、という思いがあります。
stand.fm ではどのようにしてネイティブアプリのログを BigQuery で見られるようにしているか
stand.fm アプリでは開発用のログを BigQuery へ出すために以下のような構成をとっています。
アプリから Cloud Functions へログリクエストを投げ、Cloud Functions が標準出力を経由して Cloud Logging へ出力します。
Cloud Logging には sinks という機能を利用することでログを BigQuery などの別のリソースへルーティングできます。
この機能を利用して Cloud Logging に溜めたログを BigQuery へエクスポートしています。
Cloud Logging から sinks で BigQuery への export を利用する上での制約
さて、今回のアーキテクチャを利用する上では Cloud Logging と BigQuery の制限事項を守る必要があります。
この制限を破ってしまうと、該当のログエントリが BigQuery へエクスポートされない状態になってしまいます。
sinks を利用する際の制限に関する公式ドキュメントはこちら
- Cloud Logging の割り当てと上限
- BigQuery の割り当てと上限
- sinks で BigQuery に流す際の制約・規約
さて、ログ運用の文脈で特に問題になりがちなのが BigQuery のスキーマ不一致 エラーとBigQuery の列制限でしょう。
それぞれ説明していきます。
スキーマの不一致
スキーマの不一致というのは、同名のフィールドに異なる型の値が入るケースです。
以下公式ドキュメントより引用
たとえば、最初のログエントリの jsonPayload.user_id フィールドが string の場合、そのログエントリは、そのフィールドの文字列型を持つテーブルを生成します。後になって jsonPayload.user_id を array としてロギングを開始した場合、スキーマの不一致が発生します。
Cloud Logging へは jsonPayload というフィールドに任意の構造化ログを JSON 形式で出力することが出来ます。
また、ログの実装は各開発者が好きに行えます。
ということは、たまたま同名のフィールドに異なる型の値を詰めてしまって、BigQuery に入れる段階でスキーマ不一致エラーが発生してしまいログを出せない、というケースが発生しえます。
BigQuery の列制限
BigQuery の列制限というのは、BigQuery のテーブルの列数の上限が 1 万列までと決まっていて、この上限を超えるようなログが出力されているケースです。
Cloud Logging で出力された構造化ログは BigQuery の列に一定のルールに沿って自動でマッピングされ、列がなかった場合は新たに列が追加されます。
この新たな列の追加をする際に 1万列という上限に達する、というわけです。
このエラーが出てしまうと、エラーを引き起こしたログが見られないのは当然、更に新しいログを出すのも難しくなってしまいます。
そうなると新たな sinks を作るなどして逃がすしかないといった苦しい対処を迫られます。
そもそも、普通にログを出しているだけだとそんな簡単に 1万列に達しないのですが、動的なプロパティを持つオブジェクトをログに出してしまうと簡単にこの問題にぶち当たってしまいます。
動的なを持つオブジェクトの例:
const users = {
1: { id: 1, name: "foo" },
2: { id: 2, name: "bar" },
// ...
};
上記の例のようにユーザーというデータを、ID をキーにしたオブジェクトとして保持した変数をログに書き出してしまうと、BigQuery のテーブルでは 1 ,2 , ...という列を作ってしまいます。
更に各キーのオブジェクトを列として追加するので 1.id , 1.name のような列も作られることになり、列数は一気に膨れ上がります。
以上のように、ひょんなことからこういうデータがログに紛れただけで BigQuery へのエクスポートが失敗してしまう、という事が起こってしまうのです。
ネイティブアプリ開発でのログ運用をする上での課題
基本的には「ログ実装がしくじっていると修正がリリースされるまでに 1週間以上かかってしまう・修正したからと言って一律適用されるわけではない」という点が最もネックになります。
Android、iOS のストアで配布しているアプリである以上、この 2点は避けられません。
(厳密には一律適用の仕組みは実装してはいるのですが、ユーザー体験を損なうのでログの修正程度で利用したくないという事情もあります)
先に説明したような制約は、実装者が気をつけて開発環境で検証していれば避けられることも多いですが、網羅的に検証するのはどうしても難しいです。
一例としては異常系でどんな構造のエラーが渡されるかわからないことなどが挙げられます。
React Native を使ってアプリを開発しているため、JavaScriptの制約上 try catch でハンドリングしたエラーオブジェクトはどのような型のものか事前にわからないからですね。
他にも、複数チームで開発している中で、他人の実装したログとスキーマ不一致を起こさないことを保証するのもやはり難しいでしょう。
これらの問題をどうにかして仕組みで解決しよう、というのが今回の試みです。
これらの課題を解決するためのログ出力設計
今回の問題はログを出力する際に列名・列の型が衝突しないようになっていればほとんどの問題が解決します。
実は Firebase Analytics の BigQuery Export 機能での出力形式がまさにこの問題を解決していました。
具体的には以下のような列定義になるようログを出力します。
(ドキュメントの 列 の項目より抜粋)
*ちなみに event_params の型は RECORD とだけ書かれていますが、BigQuery 上の列定義としては mode: REPEATED である点だけ注意が必要です。
つまり、 event_params の列は単なるオブジェクト( RECORD )ではなく、オブジェクトの配列になっています。
この設計はちょうど EAV(エンティティ・アトリビュート・バリュー)パターンに近い設計ですね。
event_params.key が属性名で、 event_params.value.**_value が属性値として保持される形になっています。
属性値には任意の型の値を渡しても、対応したの型の **_value 列へ出力されるようにしておく形です。
BigQuery 上でこのようなスキーマになるように Cloud Logging にログを出せば、 スキーマの不一致 及び BigQuery の列制限 には引っかからなくなります。
Cloud Logging への出力は以下のようになるイメージです。
{
"jsonPayload": {
"event_params": [
{ "key": "something data", "value": { "string_value": "some info" } },
{ "key": "user_id", "value": { "int_value": 1 } }
]
}
}
実装例
以上のような規約で各自ログを出してください、というのはあまりにもしんどいので適当にオブジェクトを渡したらいい感じに加工してくれる関数を用意しました。
入力として以下のような構造のオブジェクトを受け取ったら、
type LogPayload = {
error?: unknown;
[key: string]:
| string
| number
| boolean
| undefined
| null
| symbol
| Date
| Error;
};
出力として以下の ConvertedPayload 型ような構造に変換する、という関数です。
type ConvertedPayload = {
event_params: EventParam[];
};
type EventParam = {
key: string;
value: EventParamsValue;
};
type EventParamsValue =
| {}
| { string_value: string }
| { number_value: number }
| { boolean_value: boolean }
| { json_value: string }
| { time_value: number }
| {
error_value: {
message: string;
name: string;
stack: string;
to_string: string;
json_string: string;
};
};
こちらが JavaScript での実装例になります。
(簡単のため型は省略しています。以下のスニペットを開発者ツールに貼り付けると試せます)
function convertBqSafeData(data) {
const eventParams = Object.keys(data).map((key) => {
const value = (() => {
const target = data[key];
switch (typeof target) {
case "string":
return { string_value: target };
case "number":
if (Number.isFinite(target)) {
return { number_value: target };
}
return { string_value: target.toString() };
case "boolean":
return { boolean_value: target };
case "symbol":
return { string_value: target.toString() };
case "undefined":
return {};
case "function":
return {};
case "object":
if (target === null) {
return {};
}
if (target instanceof Date) {
return { time_value: target.getTime() };
}
if (key !== "error") {
return {};
}
const jsonStr = toJsonString(target);
if (target instanceof Error) {
return {
error_value: {
message: target.message,
name: target.name,
stack: target.stack,
to_string: target.toString(),
json_string: jsonStr,
},
};
}
return { json_value: jsonStr };
default:
return {};
}
})();
return { key, value };
});
return { event_params: eventParams };
}
function toJsonString(obj) {
try {
const str = JSON.stringify(obj);
if (str) {
const cut = str.substr(0, 10 * 1024);
if (str.length === cut.length) {
return str;
}
return JSON.stringify({
detail: "It is omitted result that object is too large",
result: cut,
});
}
return JSON.stringify({
detail: "Unexpected result that object is undefined",
});
} catch (e) {
return JSON.stringify({
detail: "Unexpected result that JSON.stringify failed",
error: e.toString(),
});
}
}
この実装によって、ログ用の関数へは LogPayload 型に合うように適当なオブジェクトを渡してあげれば簡単にログを出せるようになっています。
実装上の工夫点
基本的に JavaScript の各種データ型に対してパターン分けして BigQuery へ入れられる形に加工しているだけですが、いくつか工夫しているポイントがあるので紹介します。
型の一貫性をもたせるようにした
time_value や json_value として出力している値は BigQuery 上ではそれぞれ float,string として扱われます。
* 数値型の値は sinks で BigQuery へエクスポートすると整数値でも float 型になります。
これらの列をそれぞれ number_value , string_value に出力してしまうと、一律して日付演算や JSON.parse といった関数を適用するのが非常に難しくなってしまいます。
これは SQL アンチパターンで EAV パターンの問題点として指摘されている内容になりますね。
この問題を避けるために同じ列に対して SQL で一貫した演算を出来るように time_value や json_value という列に分離してあります。
error 列だけは特別扱いした
異常系のログは大変貴重で、極力得られる情報を漏らしたくないものです。
JavaScript はその仕様上 try catch で補足したエラーにはあらゆる型の値が来ます。
そのハンドリングを各処理に委ねるぐらいなら多少特別扱いしてでもログ関数側で賄うようにしています。
先の実装の case "object" の処理がこの特別扱いにあたります。
case "object":
// 略
// ここで `error` というキー名以外は除外している
if (key !== "error") {
return {};
}
// `error` というキーのときはなるべく情報が取れるようにしている
const jsonStr = toJsonString(target);
if (target instanceof Error) {
return {
error_value: {
message: target.message,
name: target.name,
stack: target.stack,
to_string: target.toString(),
json_string: jsonStr,
},
};
}
return { json_value: jsonStr };
任意のオブジェクトは受け付けないように制約を設けた
敢えて任意のオブジェクトは受け付けないようにしています。
- オブジェクトをそのまま置くのは当初の問題が発生してしまうので NG
- 上記関数を再帰的に適用するのはあまり大きなオブジェクトを渡されたくないため避けたい
- BigQuery では 15 階層以上の RECORD 型の列を作れないといった別の制約も考慮する必要が出てきてしまう
- JSON.stringify して詰めるにしてもログリクエストのデータ量が膨れてしまう・BigQuery のスキャン量も膨れてしまう
以上のような問題と真面目に向き合うのも大変なので避けました。
最低限フラットな構造のオブジェクトをログに出せて、Error のときだけはケアしてあれば殆どのケースで問題ないだろうとの見込みからです。
さいごに
ということで、React Native アプリから取得したログを BigQuery に入れて運用するにあたって課題になったことと、運用の安定化のために行った工夫を紹介させていただきました。
実はまだこの実装での運用は始まってないので、これで問題が全て解決したか、実際に動かしてみて運用上困ることはなかったか、といったことはまだこれからです。
リリースが楽しみです。
もしログ運用で困っていた方の参考になったら嬉しい限りです。
---
株式会社stand.fmではエンジニアを募集しています。
募集職種はこちらから:https://corp.stand.fm/recruit
Twitter で stand.fm の技術情報や note の更新をしています。ぜひフォローしてみてください!
この記事が気に入ったらサポートをしてみませんか?