Next.js にて OpenTelemetry を導入して Grafana Tempo に送信する
はじめに
今回はタイトルの通り、Next.js にて Otel による計装を行い、Trace Backend サービスである Grafana Tempo に送信して可視化してみました。
構成は以下の通りです。フロントは Cloud Run にホストし、Otel Collector はサイドカーとしてデプロイしています。また、Grafana Tempo については、すぐにエンドポイントが欲しかったので Compute Engine や GKE にセルフホストではなく Grafana Cloud を利用しました。(Grafana Cloud では Tempo だけでなく Loki や Prometheus などいろんなサービスが一気に立ち上がってくれるので便利です。) また、Cloud Trace をバックエンドとして使う選択肢もあったのですが、TraceQL を使ってみたかったので Tempo を選びました。Cloud Trace でもコンソールからメソッド・ステータスコードなどフィルターかけれるのですが (参考: Filter syntax)、Tempo の場合、TraceQL で Aggregators のような組み込み関数や演算子を使えるのでより柔軟にクエリをかけられそうだなと思いました。
そもそもフロントエンドにおいて計装は必要か
実装に移る前に、フロントエンドにおいて計装はそもそも必要かという議論があるかなと思います。これについては場合によると思っています。要件として「Observability を一定担保したい」「マイクロサービスにおいて分散トレースをしたい」といった場合はフロントエンドでも計装する必要があるかなと思います。ただ、特にそういった End-to-end の計装が不要な場合は、Chrome 前提になってしまいますが DevTool 等で十分かなと思っています。一般的にフロントで見たいような Document Load や Fetch, User interaction などの指標は DevTool でも同じように計測できてしまうからですね。
実装
Grafana Cloud
-
アカウント作成
まずは Grafana Cloud でアカウントを作成し各種サービスを立ち上げておきます。手順について詳しくは説明しませんが、Cretate free account から SSO 経由ですぐに作成できると思います。また、月当たりquotaありますが無料なので嬉しいです。Log, Trace, Profile に限ってはそれぞれ50GBまで利用できるのですごいですね。 -
APIキー払い出し
今回はせっかくなので Loki, Prometheus も利用します。まずは Create Grafana Cloud API key を参考に APIキー を払い出します。以下の画像のように各サービスに read, write scope を設定してあげましょう。
-
各種設定値を取得
次に Application Observability with OpenTelemetry Collector を参考に以下値を取得し手元に記録しておきます。あとで otel collecotr の設定ファイルに記載します。GRAFANA_CLOUD_API_KEY
GRAFANA_CLOUD_PROMETHEUS_URL
GRAFANA_CLOUD_PROMETHEUS_USERNAME
-
GRAFANA_CLOUD_LOKI_URL
(コンソールから取得したものにloki/api/v1/push
をつけてあげる必要がありました。) GRAFANA_CLOUD_LOKI_USERNAME
-
GRAFANA_CLOUD_TEMPO_ENDPOINT
(コンソールから取得したものからhttps://
を除外して、:443
というようにポート指定する必要がありました。) GRAFANA_CLOUD_TEMPO_USERNAME
Next.js
今回は Manual OpenTelemetry configuration をそのまま参考にしています。ただ、自動計装したかったので @opentelemetry/auto-instrumentations-node を追加で実装しているのと、fs 関連を Tracing しようとするとかなりの Trace が送信されてノイズになることがわかったので、無効化しています。また、instrumentation.ts はルートディレクトリに配置する必要があったのでその点注意です。
Next, create a custom instrumentation.ts (or .js) file in the root directory of the project (or inside src folder if using one):
import { NodeSDK } from '@opentelemetry/sdk-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { Resource } from '@opentelemetry/resources'
import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions'
import { SimpleSpanProcessor } from '@opentelemetry/sdk-trace-node'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
const sdk = new NodeSDK({
resource: new Resource({
[SEMRESATTRS_SERVICE_NAME]: 'next-app',
}),
spanProcessor: new SimpleSpanProcessor(new OTLPTraceExporter()),
instrumentations: [getNodeAutoInstrumentations({
// disable fs instrumentation to reduce noise
'@opentelemetry/instrumentation-fs': {
enabled: false,
},
})]
})
sdk.start()
また、next.config.mjs 内で experimental.instrumentationHook = true;
とする必要もあります。
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
instrumentationHook: true,
},
}
export default nextConfig
Otel Collector
Otel Collector の設定ファイルは Grafana Cloud 公式の設定ファイルをそのまま使っています。環境変数のところはさきほど取得した Grafana Cloud の値等を設定します。Metric を送るところでパンクしてしまったので Prometheus Remote Write Exporter の remote_write_queue の設定を追加したのと、Cloud Run のサイドカーは startupProbe ないと怒られてしまうのでそれ用に Health Check も追加しています。
# Tested with OpenTelemetry Collector Contrib v0.98.0
receivers:
otlp:
# https://github.com/open-telemetry/opentelemetry-collector/tree/main/receiver/otlpreceiver
protocols:
grpc:
http:
hostmetrics:
# Optional. Host Metrics Receiver added as an example of Infra Monitoring capabilities of the OpenTelemetry Collector
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/receiver/hostmetricsreceiver
scrapers:
load:
memory:
processors:
batch:
# https://github.com/open-telemetry/opentelemetry-collector/tree/main/processor/batchprocessor
resourcedetection:
# Enriches telemetry data with resource information from the host
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/resourcedetectionprocessor
detectors: ["env", "system"]
override: false
transform/drop_unneeded_resource_attributes:
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/transformprocessor
error_mode: ignore
trace_statements:
- context: resource
statements:
- delete_key(attributes, "k8s.pod.start_time")
- delete_key(attributes, "os.description")
- delete_key(attributes, "os.type")
- delete_key(attributes, "process.command_args")
- delete_key(attributes, "process.executable.path")
- delete_key(attributes, "process.pid")
- delete_key(attributes, "process.runtime.description")
- delete_key(attributes, "process.runtime.name")
- delete_key(attributes, "process.runtime.version")
metric_statements:
- context: resource
statements:
- delete_key(attributes, "k8s.pod.start_time")
- delete_key(attributes, "os.description")
- delete_key(attributes, "os.type")
- delete_key(attributes, "process.command_args")
- delete_key(attributes, "process.executable.path")
- delete_key(attributes, "process.pid")
- delete_key(attributes, "process.runtime.description")
- delete_key(attributes, "process.runtime.name")
- delete_key(attributes, "process.runtime.version")
log_statements:
- context: resource
statements:
- delete_key(attributes, "k8s.pod.start_time")
- delete_key(attributes, "os.description")
- delete_key(attributes, "os.type")
- delete_key(attributes, "process.command_args")
- delete_key(attributes, "process.executable.path")
- delete_key(attributes, "process.pid")
- delete_key(attributes, "process.runtime.description")
- delete_key(attributes, "process.runtime.name")
- delete_key(attributes, "process.runtime.version")
transform/add_resource_attributes_as_metric_attributes:
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/processor/transformprocessor
error_mode: ignore
metric_statements:
- context: datapoint
statements:
- set(attributes["deployment.environment"], resource.attributes["deployment.environment"])
- set(attributes["service.version"], resource.attributes["service.version"])
exporters:
otlp/grafana_cloud_traces:
# https://github.com/open-telemetry/opentelemetry-collector/tree/main/exporter/otlpexporter
endpoint: "${env:GRAFANA_CLOUD_TEMPO_ENDPOINT}"
auth:
authenticator: basicauth/grafana_cloud_traces
loki/grafana_cloud_logs:
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter/lokiexporter
endpoint: "${env:GRAFANA_CLOUD_LOKI_URL}"
auth:
authenticator: basicauth/grafana_cloud_logs
prometheusremotewrite/grafana_cloud_metrics:
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/exporter/prometheusremotewriteexporter
endpoint: "${env:GRAFANA_CLOUD_PROMETHEUS_URL}"
auth:
authenticator: basicauth/grafana_cloud_metrics
add_metric_suffixes: false
remote_write_queue:
enabled: True
queue_size: 100000
num_consumers: 50
extensions:
health_check:
basicauth/grafana_cloud_traces:
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/basicauthextension
client_auth:
username: "${env:GRAFANA_CLOUD_TEMPO_USERNAME}"
password: "${env:GRAFANA_CLOUD_API_KEY}"
basicauth/grafana_cloud_metrics:
client_auth:
username: "${env:GRAFANA_CLOUD_PROMETHEUS_USERNAME}"
password: "${env:GRAFANA_CLOUD_API_KEY}"
basicauth/grafana_cloud_logs:
client_auth:
username: "${env:GRAFANA_CLOUD_LOKI_USERNAME}"
password: "${env:GRAFANA_CLOUD_API_KEY}"
connectors:
grafanacloud:
# https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/connector/grafanacloudconnector
host_identifiers: ["host.name"]
service:
extensions:
[
health_check,
basicauth/grafana_cloud_traces,
basicauth/grafana_cloud_metrics,
basicauth/grafana_cloud_logs,
]
pipelines:
traces:
receivers: [otlp]
processors:
[resourcedetection, transform/drop_unneeded_resource_attributes, batch]
exporters: [otlp/grafana_cloud_traces, grafanacloud]
metrics:
receivers: [otlp, hostmetrics]
processors:
[
resourcedetection,
transform/drop_unneeded_resource_attributes,
transform/add_resource_attributes_as_metric_attributes,
batch,
]
exporters: [prometheusremotewrite/grafana_cloud_metrics]
metrics/grafanacloud:
receivers: [grafanacloud]
processors: [batch]
exporters: [prometheusremotewrite/grafana_cloud_metrics]
logs:
receivers: [otlp]
processors:
[resourcedetection, transform/drop_unneeded_resource_attributes, batch]
exporters: [loki/grafana_cloud_logs]
Cloud Run
Cloud Run は以下のような形になります。AP 側と PORT 合わせるのと正しいOTEL_EXPORTER_OTLP_ENDPOINT
を指定すること、startupProbe を設定しているところがポイントですね。
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: nextjs-otel-grafanatempo
annotations:
run.googleapis.com/launch-stage: BETA
spec:
template:
metadata:
annotations:
run.googleapis.com/container-dependencies: "{app:[collector]}"
spec:
containers:
- image: asia-northeast1-docker.pkg.dev/PROJECT_ID/nextjs-otel-grafanatempo-repo/nextjs-ote-grafanatempo
name: app
ports:
- containerPort: 3000
env:
- name: "OTEL_EXPORTER_OTLP_ENDPOINT"
value: "http://localhost:4317"
- image: asia-northeast1-docker.pkg.dev/PROJECT_ID/nextjs-otel-grafanatempo-repo/otel-collector
name: collector
startupProbe:
httpGet:
path: /
port: 13133
結果
以下のように Cloud Run から Tempo に Trace 送信されていることを確認できました。
また、以下は Application ですが、Latency (Duration), RPS など見れて良い機能だなと思いました。
また、きちんと Span の親子化ができていれば以下のような Service Map もみれるのでこれは良い機能だなと思いました。一気にセービスメッシュ感がでますね。
おわりに
Next.js + Otel + Grafana Tempo の構成でフロントでの計装を試してみました。フロントの自動計装が思ったより簡単だったのと、Grafana Cloud の Application Observability や Service Map が想定外に感触が良かったので積極的に Grafana 使っていきたいと思いました。フロント計装構成の参考になれば幸いです。また、今回の実装は以下リポジトリに格納してあるので参考にしてみてください。
Discussion