Next.js app on Vercel でテレメトリデータを毎回ちゃんと送信するには
- Next.js app on Vercel に対してテレメトリを OTEL で計装している
- 例えばある関数が呼ばれるたびに
Counter.add()
されるとする - 計装対象の関数は毎回正常終了するが、OTEL collector から o11y platform へのテレメトリデータの送信が不安定
- 受信に成功したときは、カウンタが一気に上昇しているように見える
- カウンタの場合には積算値としては変わらないが、途中のデータは失われていると思われる
- 受信に成功したときは、カウンタが一気に上昇しているように見える
- 仮説: 関数が return して Vercel Function が休止状態になることによって、非同期でのテレメトリ送信がなされないのではないか
検証対象
-
検証 1
: ふつうに計装したままでは、テレメトリが送信されたりされなかったりすることを確認する -
検証 2
: 明示的に flush() すればテレメトリが確実に送信されることを確認する
追加で気になること
- forceFlush() すると関数の所要時間自体は延びてしまうのではないか
- trace のみ
forceFlush()
が使われることが@vercel/otel
側で保証されているように見える。ログやメトリクスでも可能か、実装して試してみる。よさそうなら PR にする
検証手順
- 最小限の Next.js app を Vercel にデプロイする
- 適当な関数を画面からボタンで呼べるようにする
- 関数の所要時間をロギングし、Vercel Logs から確認できるようにする
- Grafana Cloud をセットアップする
- 関数に、呼び出しごとにインクリメントするカウンタを計装する
検証 1
検証 2
1. 最小限の Next.js app を Vercel にデプロイする
Next.js app の作成
npx create-next-app@latest research-nextjs-vercel-telemetry --use-npm
Need to install the following packages:
create-next-app@14.2.15
Ok to proceed? (y) y
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like to use `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to customize the default import alias (@/*)? … No / Yes
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in /Users/ahayashi/dev/research-nextjs-vercel-telemetry.
Using npm.
Initializing project with template: app-tw
Installing dependencies:
- react
- react-dom
- next
Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
added 138 packages, and audited 139 packages in 18s
31 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
Initialized a git repository.
Success! Created research-nextjs-vercel-telemetry at /Users/ahayashi/dev/research-nextjs-vercel-telemetry
npm notice
npm notice New minor version of npm available! 10.7.0 -> 10.9.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v10.9.0
npm notice To update run: npm install -g npm@10.9.0
npm notice
npm run dev
でたった
Vercel にデプロイ
できた
2. 適当な関数を画面からボタンで呼べるようにする
検証 1
と 検証 2
に備えて下記 2 つの関数を作る
async_log_export()
force_flush()
ひとまずログだけ出すようにした
それぞれを呼ぶボタンを画面に追加
それぞれの関数からのログを Vercel で確認できた
3. 関数の所要時間をロギングし、Vercel Logs から確認できるようにする
も完了
4. Grafana Cloud をセットアップする
ひとまずアカウントつくった
下記を参考に OTLP エンドポイントを使えるようにする
Vercel の環境変数にもろもろを設定した
5. 関数に、呼び出しごとにインクリメントするカウンタを計装する
下記で計装した
ローカルのボタンを押すと両関数から Grafana Cloud 上にメトリクスが届くことを確認
検証 1
: ふつうに計装したままでは、テレメトリが送信されたりされなかったりすることを確認する
ローカルからは届くことを確認できた、つまり最低限の計装は完了しているはずだが、production の async_counter()
用ボタンを押しても、Grafana Cloud には届かない
いや、なんかとれてしまったな
検証 1
の時点で、事前に別環境(業務)で確認していた挙動と違ってしまった。
もっともそちらの環境は下記のようにいくつか違いがあるのだが、、
- Next.js のバージョンが canary 系の新しいものを使っている
- なので nextConfig に experimental.instrumentationHook がない - Sentry SDK を使っている
今はもっとまずいものを見つけたのでそちらを調べる
今はもっとまずいものを見つけたのでそちらを調べる
Counter メトリクスのくせに値が減少している
ここのところ(青線)
青線は production (Vercel)環境なので、Function に利用されているコンテナが途中で変わったんだろうか。
とにかくこれは counter メトリクスとしての利用を想定している場合、重大なバグにつながる恐れがある。
カウンタはある意味ステートフルなメトリクスだが、Function ベースのアーキテクチャにおいてステートフルなメトリクスを使うのは危険かもしれない。
カウンタを使いたい局面ではゲージを使っておき、分析環境側で cumsum する方法を検討する。
ちなみに Vercel 環境はしばらく触らずに放置しておくと値が Null になる
再び触ってみたが、このケースでは値を忘れていた
そして、値は届いているものの、やはり挙動が不安定。
このケースでは 10 回程度関数を呼んだが、メトリクスは 2 つしか確認できなかった
instrumentation.js
にいまいち不安があるので追ってみる
利用する Next.js は現時点の最新ということで v15.0.0-rc.1
instrumentation.js
を使うライブラリとしては、はセットアップウィザードがついてる Setry を使ってみる。
バージョンは8.34.0
https://github.com/Rindrics/research-nextjs-vercel-telemetry/commit/87ba51db261c8ca7b173b2d512a44a2c8e0bb561 で Sentry セットアップした
sentry-example-page からエラー送出してみた。
なんの問題もない。極めて反応がよい。
instrumentation.js
はうまく動いていそう。
OTEL による計装もこの感じで動いてほしいので Sentry SDK による instrumentation.js
まわりの利用を見てみる
なお利用している Next.js は 15 系なので next.config
に instrumentationHook
はない
もろもろ調査して挙動がわかったので先にまとめだけ貼っておく
コード: https://github.com/Rindrics/research-nextjs-vercel-telemetry/tree/v0.0.3 https://github.com/Rindrics/research-nextjs-vercel-telemetry/tree/v0.0.4
単純に計装した場合
- テレメトリが計装部あるいはコレクタに溜まってしまう
- 起きること: 関数呼び出し時にテレメトリが生成されるが、それがコレクタによって送信される前に Lambda 関数が休止してしまうため、そのテレメトリは次回の呼び出し時にテレメトリバックエンドに送信される
forceFlush()
を waitUntil()
した場合
- 意図通りに呼び出しごとにテレメトリが届く
📝 registerOTel()
に渡した metricReader の使われ方
meterProvider.addMetricReader()
される
SDK の this.meterProvider
となる
sdk.shutdown()
で meterProvider.shutdown()
が promise に追加される
meterProvider
とは
@opentelemetry/sdk-metrics
MeterProvider.addMetricReader()
で collector インスタンスが作られる
つまりここではアプリリソースを使う collector インスタンスができる
📝 collector はテレメトリをどのように export しているか
→ metricReader に委譲してる
MeterProvider の shutdown()
では MeterProvider が保持している collector 全ての shutdown()
について Promise 解決を待つ
実際に shutdown()
しているのは metricReader
PeriodicExportingMetricReader from "@opentelemetry/sdk-metrics
を使った場合には
PeriodicExportingMetricReader
は基本的に MetricReader
なのでまずそちらを見る
MetricReader
の shutdown()
は実際には onShutdown()
を実行している
MetricReader
の onShutdown()
には実装が入っていないので
PeriodicExportingMetricReader
の onShutdown()
は shutdown しかしていない
メトリクスを実際に export しているのは doExport()
だが、これは private メソッドである _doRun()
の中の関数なので外からは呼べない
外からは呼べない
いや、forceFlush()
で _runOnce()
経由で呼べるか
いや、forceFlush() で _runOnce() 経由で呼べるか
だとするとこれいらないか
だとするとこれいらないか
ほんとだ、forceFlush()
を waitUntil()
するだけで届いた
ほんとだ、forceFlush() を waitUntil() するだけで届いた
ここまではふつうにレスポンス返す server action だったので streaming 使う server action の場合の動作を見ていく
forceFlush()
だけ waitUntil()
したうえで
streamObject() from ai
を使う
ログは届いてる
メトリクスが届いてない
メトリクスが届いてない
いま OTLPMetricExporter from "@opentelemetry/exporter-metrics-otlp-http"
を使ってるので
いやちょっと何見てるか忘れた
PeriodicExportingMetricReader
を見ると、doExport()
の中に internal._export(this._exporter, resourceMetrics);
というのが見える
この internal
というのは @opentelemetry/core
にあるらしい
ここを見ると
ここに _export()
があるのがわかり、結局 exporter.export()
を呼んでる
結局 exporter.export() を呼んでる
で、
いま OTLPMetricExporter from "@opentelemetry/exporter-metrics-otlp-http" を使ってるので
このあたりを見てたんだった
OTLPMetricExporterBase
の this._otlpExporter.export()
を見る
this._otlpExporter
とは
T
で受けたものが入る
OTLPExporterNodeProxy
に継承されてるが そっちには export()
はない
OTLPExporterBase
にあった
export()
の中でテレメトリ送信を定義しているのは send()
send()
の実装:
と思いきや this._transport
に委譲されてる
this._transport
とは
new RetryingTransport
RetryingTransport
とは
実際に呼ばれている send()
はこれ
いま waitUntil()
している内容で RetryingTransport.send()
が実行されているか確認するために、spentelemetry-js をパッチしてみる