🧀

Next.js にて OpenTelemetry を導入して Grafana Tempo に送信する

2024/06/07に公開

はじめに

今回はタイトルの通り、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 LoadFetch, User interaction などの指標は DevTool でも同じように計測できてしまうからですね。

実装

Grafana Cloud

  1. アカウント作成
    まずは Grafana Cloud でアカウントを作成し各種サービスを立ち上げておきます。手順について詳しくは説明しませんが、Cretate free account から SSO 経由ですぐに作成できると思います。また、月当たりquotaありますが無料なので嬉しいです。Log, Trace, Profile に限ってはそれぞれ50GBまで利用できるのですごいですね。

  2. APIキー払い出し
    今回はせっかくなので Loki, Prometheus も利用します。まずは Create Grafana Cloud API key を参考に APIキー を払い出します。以下の画像のように各サービスに read, write scope を設定してあげましょう。

  1. 各種設定値を取得
    次に 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):

instrumentation.node.ts
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;とする必要もあります。

next.config.mjs
/** @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 も追加しています。

config.yaml
# 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 を設定しているところがポイントですね。

service.yaml
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 使っていきたいと思いました。フロント計装構成の参考になれば幸いです。また、今回の実装は以下リポジトリに格納してあるので参考にしてみてください。

https://github.com/ppluuums-jp/nextjs-otel-grafanatempo

Discussion