Observe をリリース:Expo アプリ向けのパフォーマンスモニタリングが正式版に
Observe が正式に利用可能になりました。React Native の起動パフォーマンスと単一画面のパフォーマンスを実機上で計測し、ビルドと更新のたびに紐付けます。
日本語
コピー

あなたのアプリは本番環境で実際どう動いているのか? この問いに答えるのは驚くほど難しい。クラッシュレポートツールはアプリが落ちたときは教えてくれるが、静かに遅くなり始めたときは何も言わない。しかも最近ネイティブビルドをいくつか出し、JavaScript の更新も何度か行っているなら、どれが性能劣化の原因なのかを突き止めるのはほぼ勘頼りだ。
これを解決するのが Observe だ。実際のユーザーのデバイスでアプリの起動がどれだけ速いか、各画面が使えるようになるまでどれだけかかるかを計測し、リリースしたネイティブビルドと EAS Update のたびにグラフ上へマーカーを打つ。マーカーをクリックすれば、バージョン番号やビルド番号、更新 ID と、その時点に対応する指標の値がわかる。
Observe は 5 月からパブリックベータで、本日から正式に利用可能になった。ルートごとの指標、セッションタイムライン、agent への引き継ぎフローに対応し、無料から始められる従量課金制だ。
可観測性プラットフォームは 2 つの部分からなる。
-
オープンソースライブラリの
expo-observe。標準の OpenTelemetry 仕様に沿って指標・イベント・ログを収集し、collector へ送信する。 -
EAS Observe は、そのデータを保存・分析するサービス。
expo-observeのデフォルト collector だが、endpoint を設定して自前の collector を使うこともできる。自前の collector にすると失うのはリリースの帰属だけだ。ある指標を、それを生み出した EAS ビルドと commit に結びつけるにはビルドパイプラインが要る。

モバイル監視とWeb監視はなぜ別物なのか
Webでは、バージョンを1つリリースすれば、次にページを読み込んだ全員がそれを実行する。モバイルはそうはいかない。ユーザーは自分のペースで更新し、中には永遠に更新しない人もいる。さらにネイティブビルドの上にJavaScriptのホットアップデートを重ねると、組み合わせの数は倍々で増えていく。
ネイティブバージョンが3つ、アップデートが2回。それだけで本番環境には5つのバージョンが同時に生きていることになる。どれも実質的には性能特性の異なる別々のアプリで、何百もの機種、10年分にわたるOSバージョンで動いている。
監視ツールが「リリース」を単なるバージョン文字列としてモデル化しているなら、この状況は表現できない。5つの異なるアプリを平然と平均して1つのp90として出すだけだ。
計測コードなしで取れる起動指標
ライブラリを入れてルートレイアウトを1回ラップするだけで、3つの起動指標が送信され始める。
-
起動時間:プロセス生成からシステムのメモリ割り当て完了まで。コールドスタートとウォームスタートに分かれる
-
Bundle読み込み:JavaScriptバイトコードを読み込んで実行するまで
-
レンダリング時間(TTR):ネイティブの起動が終わってから、ルートのReactコンポーネントが最初にレンダリングされるまで
npx expo install expo-observe
import { ObserveRoot } from 'expo-observe';
function RootLayout() {
return <Stack />;
}
export default ObserveRoot.wrap(RootLayout);

Time to interactive(TTI) は、自分でコードを一行も書かない限り計測できない唯一の指標だ。アプリが実際にいつ入力を待ち受けられるようになったかを検出できるライブラリは存在しない。起動画面の処理がいつ終わり、最初の画面がいつ実データを受け取ったかを知っているのは自分のコードだけだ。だからこの呼び出しは自分で書くしかない:
import { useObserve } from 'expo-observe';
export default function HomeScreen() {
const { markInteractive } = useObserve();
useEffect(() => {
if (data) markInteractive();
}, [data]);
// ...
}
どの指標についても、中央値、平均、最小、最大、p90、p99 を出す。まず中央値を前のバージョンと比べ、必要なら最も遅いイベントまで掘り下げる。
すべてのセッションにデバイスコンテキストが付く
TTI イベントにはコンテキストが自動で付いてくる。アプリのバージョンとビルド番号、環境、国、OS とそのバージョン、デバイスモデル、Expo のバージョン、React Native のバージョン、言語タグ、アプリ識別子、ルート。
さらに、Web 由来のツールでは収集できないフィールドもある。ブラウザのタブの中にはそもそも存在しないものだからだ:
-
フリーズフレーム、スローフレーム、合計遅延
-
低電力モード
-
サーマル状態
-
ネットワーク種別と接続の有無
p99 TTI が 4 秒というのは、サーマルスロットリングを起こした中位の Android 端末がセルラー回線にいるのと、wifi につないだ新しい iPhone にいるのとでは、まったく意味が違う。これらのフィールドがなければ、目に入るのは説明のつかない数字だけだ。
ビルドとアップデートごとのリリースマーカー
ネイティブビルドと配信したアップデートはそれぞれ、最初のイベントが届いた時点でグラフにリリースマーカーを打つ。起動カードはどれも最新バージョンと前のバージョンを並べて表示するので、フィルタは要らない。ダッシュボード全体を特定のアプリバージョン、特定のネイティブビルド、あるいは特定のアップデートに絞り込むこともできる。

同じアプリバージョンでもビルドが同じとは限らない。Observe はそこを区別する。手元のデータから一例を挙げると、バージョン 1.0.1 には 2 つの異なるビルドがあり、片方はインストールが 1 回だけだった。明らかにテストビルドで、バージョン番号をリリース版として扱う統計ツールには見えない。
この仕組みで捕まえられる故障のひとつが、JavaScript の更新でレンダリングが遅くなったケースだ。クラッシュはしないのでクラッシュレポートは静かなままだし、アプリ審査も通らないので外部の何かがフラグを立てることもない。Observe がなければ、ユーザーの苦情を待つしかなかった。今は更新を出した瞬間にグラフにマーカーが付き、曲線がその直後に跳ね上がる。
更新のダウンロード時間には専用のタブがあり、更新ごとのテーブルでダウンロード数、中央値、p90 を確認できる。たいていここで、4MB のアセットのせいでセルラーネットワークのユーザーがアプリを使い始めるまで数秒余計に待たされていることに気づく。
詳細は Builds and updates ドキュメント を参照。
ルート別のメトリクス
起動メトリクスは最初の画面しか映さない。SDK 56 以降では、Expo Router または React Navigation の統合を有効にすると、ルートごとのコールドスタートとウォームスタートの初回レンダリング時間が自動で付く。これらの画面に markInteractive() 呼び出しを追加すれば、ルートごとのインタラクション準備完了時間も取れる。
import { Observe } from 'expo-observe';
Observe.configure({
integrations: { 'expo-router': true },
});
初回レンダリングと 2 回目以降を別々に集計しているのは実用的だ。ある画面に初めて入るときと 5 回目に入るときでは、たいてい別種のパフォーマンス問題だからだ。
同じタイムライン上のカスタムイベント
Observe.logEvent() はシリアライズ可能な任意のプロパティを持つイベントを送出でき、起動メトリクスと同じセッションタイムラインに載る。
import { Observe } from 'expo-observe';
Observe.logEvent('payment_failed', { reason: 'card_declined', attempt: 2 });
本当に役立つのはユーザー 1 人の完全なタイムラインだ。bundle のロード、初回レンダリング、インタラクション準備完了、コールドスタート、続いて payment_failed / reason: card_declined、そして更新のダウンロードまでが順番に並び、デバイスコンテキストもすべて付いてくる。
エラーレポート(プレビュー)
SDK 57 以降では、expo-observe が JavaScript エラーも記録する。未処理のエラーは自動で、レンダリングエラーは ObserveErrorBoundary 経由で、処理済みのエラーは Observe.reportError 経由で記録される。これらは同じビルドのパフォーマンスメトリクスの隣の Errors タブに表示されるので、TTR を押し上げたバージョンとエラーを持ち込んだバージョンを同じビューで確認できる。

本番ビルドのスタックトレースは圧縮後のバンドルを指している。読めるようにするには、eas.json のビルドプロファイルで uploadSourceMaps: true を設定する。EAS Build はビルドごとに source map をアップロードするので、そのビルドで発生したエラーは自動的にシンボリケーションされる。エラー報告は現在プレビュー段階で、EAS Update の source map とネイティブクラッシュ報告はまだこれから。
ちなみに:Sentry など他のクラッシュ報告ツールを使っているなら、そのまま使い続ければいい。Observe はまだネイティブクラッシュを捕捉できない(今のところは!)が、併用しても問題ない。
リグレッションを agent に任せる
異常を見つけることと、それを理解することは別の話だ。p90 TTI にスパイクが出たら、デバイス、バージョン、国、ビルドを手作業で突き合わせるしかない。
Dashboard には「Hand off to your AI assistant」ボタンがある。現在の dashboard の状態を、agent 向けに書き下ろした prompt としてコピーする。中には CLI コマンドがいくつか示されていて、Claude Code、Cursor、Codex にそのまま貼り付けて、前回の release がなぜリグレッションしたのかを聞ける。
設定まで agent にやらせたいなら、expo.dev/expo-skills に3つの skill が公開されている:expo-observe-setup、expo-observe-metrics、expo-observe-queries。
制限
Observe は正式に利用可能になったが、完成したわけではない。現時点でできないことは以下の通り:
-
アラートはまだない。 今は自分で dashboard を見るか、
eas-cliで定期的に指標を自動チェックするしかない。メール、Slack、webhook のアラートは開発中。 -
session replay、プロダクト分析、バックエンド tracing はない。 Observe が測るのは app のパフォーマンスだ。APM ではないし、リクエストを追ってあなたのサービスの中まで入っていくこともない。
-
ネイティブクラッシュ報告はまだない。 JavaScript のエラー報告は SDK 57 以降でプレビュー段階;ネイティブクラッシュには依然として Sentry のようなサービスが必要。
-
対応は iOS、Android、tvOS のみで、Expo Go では動かない。 development build か production build が必要。
-
Expo SDK 55 以上、かつ EAS プロジェクトが必要。
-
Observe を有効にするにはバイナリの再ビルドが必要。 現状、
eas.jsonから直接オンにすることはできない。 -
ユーザーはインストール単位で匿名化される。 問題が起きたセッションは特定できるが、それを名前のある顧客に紐づけることはできない。
-
フレームデータは TTI イベントに紐づく。 ある画面で
markInteractive()呼び出しがなければ、対応するフレームデータもない。 -
サンプリングは外挿しない。
sampleRateを下げると、パーセンタイルは保持したデータ上で計算されるのであって、再重み付けして推計されるわけではない。 -
米国ホスティングのみ。 EU データレジデンシーは未提供。
料金
料金はイベント使用量に基づく課金で、dashboard に推定使用量が表示されるから、請求で不意を突かれることはない。
Free プランは月10万イベントを含み、起動指標とビルド・アップデートのオーバーレイを提供する。Starter プラン(月19ドル)と Production プラン(月199ドル)はいずれも50万イベントを含み、超過分は使用量に応じて課金される。追加イベントは100万件あたり5.00ドル、使用量が増えると4.75ドル、さらに4.50ドルに下がる。
指標データは最低90日間保持される。詳細は料金ページを参照。
Observe をはじめる
動画:Observe をプロジェクトに追加する方法——expo.dev で視聴
入門ガイドに沿って進めるか、agent に expo-observe-setup skill でやらせてもいい。アプリを再ビルドすれば、プロジェクトの dashboardの Observe タブに指標が表示され始める。

問題やバグ報告は Expo Discord の #eas チャンネルへお寄せいただくか、今週のライブ配信でぜひお持ち込みください!