OpenTelemetryに入門してみる(トレースを取得してみる)

Updated:

とりあえずブラウザ - サーバ間のトレース が取得できるところまでを試してみる。

試した結果は https://github.com/piyoppi/otel-browser-playground にある。 このサンプルアプリケーションはVite + Hono Expressの構成で、シンプルなバックエンドをもつフロントエンドアプリケーションを想定している。

デモ

公式ドキュメントに、Dockerで動作する、いくつかのサービスで構成されるアプリケーションの計測に関するデモがある。雰囲気をつかむのに手っ取り早く動かすことができる。

概念の理解

日本語のドキュメントがある。

OpenTelemetryは、シグナル(トレース、メトリクス、ログ、バゲッジ)を収集、処理、エクスポートするための仕様やインタフェース、および計装コンポーネントなどで構成されている

  • トレース: あるエンドポイントにアクセスしたときに何がどのくらいの時間行われているかを追跡するもの(データフェッチ, 描画, などなど...)。これらはスパンによって表現される。
  • メトリクス: 測定値。(CPU使用率、レスポンスタイム、とかとか...)

ブラウザ側のイベントをトレースしてみる

OpenTelemetryのGetting Started を見ながらソースコードを書いてみる。 内容を理解するために、コードにコメントを書いてみた。

import { BatchSpanProcessor, WebTracerProvider } from '@opentelemetry/sdk-trace-web'
import { DocumentLoadInstrumentation } from '@opentelemetry/instrumentation-document-load'
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch'
import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request'
import { ZoneContextManager } from '@opentelemetry/context-zone'
import { registerInstrumentations } from '@opentelemetry/instrumentation'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { Resource, browserDetector } from '@opentelemetry/resources'
import { detectResourcesSync } from '@opentelemetry/resources/build/src/detect-resources'
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'

// OTLPTraceExporter を用いてトレースをサーバに送信するexporterを定義
const exporter = new OTLPTraceExporter({
  // Collectorのエンドポイントを指定
  url: 'http://localhost:55681/v1/traces',
});

// リソース(=テレメトリを生成する対象を示す属性値)の定義
// see: https://opentelemetry.io/ja/docs/concepts/resources/
let resource = new Resource({
  // サービス名を設定する
  [ATTR_SERVICE_NAME]: 'my-service-frontend',
});

const detectedResources = detectResourcesSync({ detectors: [browserDetector] });
resource = resource.merge(detectedResources);

// トレーサープロバイダー(= Tracerのファクトリ)を生成
// https://opentelemetry.io/ja/docs/concepts/signals/traces/#tracer-provider
// トレーサープロバイダーからトレーサーが生成され、トレーサーがスパンを生成する
const provider = new WebTracerProvider({
  resource,
  // BatchSpanProcessorを使うことで、いくつかのスパンをまとめてエクスポートするようになる
  spanProcessors: [new BatchSpanProcessor(exporter)],
});

// provider.registerを呼び出すことで、グローバルな部分にトレーサープロバイダが登録され、
// 各種instrumentationsで利用されるということぽい
// ref: https://github.com/open-telemetry/opentelemetry-js/blob/c00f36ee436f58906ff82cd9da978c44b69ec1e9/packages/opentelemetry-sdk-trace-base/src/BasicTracerProvider.ts#L119
provider.register({
  contextManager: new ZoneContextManager(),
})

// instrumentations (=計測器)を登録する
registerInstrumentations({
  instrumentations: [
    // HTMLのロード時の様子を計測する
    new DocumentLoadInstrumentation(),
  ],
})

Collectorでシグナルを収集して可視化する

生成したトレースを収集するサーバをたてる。これをCollectorと言う。

otel-collector-config.yml を記述してCollectorを設定する

receivers:
  otlp:
    protocols:
      http:
        # CORSのための設定(フロントエンドアプリケーションがlocalhost:5174で動作しているという前提がある)
        cors:
          allowed_origins:
            - "http://localhost:5174"
        endpoint: "0.0.0.0:55681"
      grpc:

exporters:
  # debug exporterを使うとControllerのサーバログにトレースが流れるようになる(=トレースが流れていることを確認できる)
  debug:
  otlp:
    endpoint: "tempo:4317"
    # (開発環境での動作確認のためTLSを無効化している)
    tls:
      insecure: true

service:
  # Pipelineを定義する
  # ref: https://opentelemetry.io/docs/collector/architecture/#pipelines
  # シグナルはreceiversから取得され、processorsを経てexportersに出力される
  pipelines:
    # (今回はトレースしか設定しない)
    traces:
      receivers: [otlp]
      exporters: [debug, otlp]

docker-compose.yml でcollectorを起動できるようにしておく

version: '3.8'

services:
  sample-otel-collector:
    image: otel/opentelemetry-collector
    container_name: sample-otel-collector
    command: ["--config=/etc/otel-collector-config.yml"]
    volumes:
      - ./otel-collector-config.yml:/etc/otel-collector-config.yml
    ports:
      - "55681:55681"
    networks:
      - monitoring

networks:
  monitoring:

トレースはどこかに記録しておかないといけない。このためのストレージエンジンとしてTempoをたてる。 Tempoは分散トレーシングのためのバックエンドで、トレースを保存、リストアする役割。S3などのオブジェクトストレージを保存先として利用できる。

Tempoのリポジトリ に、シグナルの可視化を担うGrafanaとの連携サンプルがある。 Grafanaは様々なシグナルを可視化するためのウェブアプリケーション(というのが現状の筆者の認識)。様々なデータソースを指定することができ、ダッシュボードを構成できる。

tempo.yml を設定しておく(筆者はまだ設定項目を詳細に理解していないので、説明は割愛)。

TempoとGrafanaを起動するように追記したdocker-compose.ymlは最終的には以下のようになった。

version: '3.8'

services:

  sample-otel-collector:
    image: otel/opentelemetry-collector
    container_name: sample-otel-collector
    command: ["--config=/etc/otel-collector-config.yml"]
    volumes:
      - ./otel-collector-config.yml:/etc/otel-collector-config.yml
    ports:
      # フロントエンドアプリケーションから収集するためのポートをあけておく
      - "55681:55681"
    networks:
      - monitoring

  # Tempo / Grafana 関連の記述は以下のサンプルコードから引用
  # ref: https://github.com/grafana/tempo/tree/main/example/docker-compose/
  init:
    image: &tempoImage grafana/tempo:latest
    user: root
    entrypoint:
      - "chown"
      - "10001:10001"
      - "/var/tempo"
    volumes:
      - ./tempo-data:/var/tempo
    networks:
      - monitoring

  memcached:
    image: memcached:1.6.29
    container_name: memcached
    ports:
      - "11211:11211"
    environment:
      - MEMCACHED_MAX_MEMORY=64m  # Set the maximum memory usage
      - MEMCACHED_THREADS=4       # Number of threads to use
    networks:
      - monitoring

  tempo:
    image: *tempoImage
    container_name: tempo
    command: [ "-config.file=/etc/tempo.yaml" ]
    volumes:
      - ./tempo.yaml:/etc/tempo.yaml
      - ./tempo-data:/var/tempo
    ports:
      - "3200:3200"   # tempo
      - "9095:9095"   # tempo grpc
      - "4317:4317"   # otlp grpc
      - "4318:4318"   # otlp http
    depends_on:
      - init
      - memcached
    networks:
      - monitoring

  grafana:
    image: grafana/grafana:11.2.0
    environment:
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
      - GF_AUTH_DISABLE_LOGIN_FORM=true
      - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor metricsSummary
      - GF_INSTALL_PLUGINS=https://storage.googleapis.com/integration-artifacts/grafana-exploretraces-app/grafana-exploretraces-app-latest.zip;grafana-traces-app
    ports:
      - "3000:3000"
    networks:
      - monitoring

networks:
  monitoring:

volumes:
  grafana-storage:
  tempo-storage:

ここまでの状態で、フロントエンドアプリケーションと計測バックエンド(上記のdocker-compose.ymlに記述したものたち)を起動したところ、ブラウザ側でHTMLの描画に利用される各種アセットの取得のトレースを得ることができた。

trace1

Node.jsバックエンドのトレース

Node.jsバックエンドを以下のように実装する。内容を理解するために、ソースコードにコメントを入れる。

Node.jsの自動計装がどのように実装されているかについては、ブログ記事 にも記述した。

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { NodeSDK } from '@opentelemetry/sdk-node'
import { trace } from '@opentelemetry/api'
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
import {
  PeriodicExportingMetricReader,
  ConsoleMetricExporter,
} from '@opentelemetry/sdk-metrics'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'
import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'
import { Resource } from '@opentelemetry/resources'
import { createServer as createViteServer } from 'vite'

const __dirname = path.dirname(fileURLToPath(import.meta.url))

const sdk = new NodeSDK({
  resource: new Resource ({
    [ATTR_SERVICE_NAME]: 'my-service-backend',
  }),
  // トレースの出力
  // BatchSpanProcessor (スパンをある程度まとめて送信するProcessor) が自動的に適用される
  traceExporter: new OTLPTraceExporter({
    url: 'http://localhost:55681/v1/traces',
  }),
  // メトリクスの出力
  // とりあえず今はログに出すだけにしておく
  metricReader: new PeriodicExportingMetricReader({
    exporter: new ConsoleMetricExporter(),
  }),
  // Node.jsでひととおり使いそうな計装モジュールを適用しておく
  instrumentations: [getNodeAutoInstrumentations()],
})

sdk.start()

const vite = await createViteServer({
  server: { middlewareMode: true },
  appType: 'custom'
})

import express from 'express'

const app = express()
const router = express.Router()

app.use(vite.middlewares)

// 適当なHTMLを返却するエンドポイント
router.get('/', async (_req, res) => {
  let template = fs.readFileSync(
    path.resolve(__dirname, 'index.html'),
    'utf-8',
  )

  await new Promise((resolve) => setTimeout(resolve, 500))

  // 現在のアクティブなスパンの下にフロントエンド側のトレースを紐づけたいので、現在のスパンを取得する
  // `getNodeAutoInstrumentations()` で提供される計装モジュールには Express のエンドポイントにアクセスしたことをトレースとして
  // 記録するモジュールも含まれているので、このトレースのスパンが取得されることを期待している
  const span = trace.getActiveSpan()
  if (span) {
    // クライアント側で用いている `@opentelemetry/instrumentation-document-load` では
    // meta[name="traceparent"]タグによるコンテキスト伝搬(https://opentelemetry.io/ja/docs/concepts/context-propagation/)を
    // サポートしている。
    // なお、traceparentの値は https://www.w3.org/TR/trace-context/ に準拠している
    const traceparent = `00-${span.spanContext().traceId}-${span.spanContext().spanId}-01`;
    template = template.replace(`<!--traceparent-->`, () => traceparent)
  }

  res.send(template)
})

router.get('/api/test', async (_req, res) => {
  await new Promise((resolve) => setTimeout(resolve, 1000))

  res.json({message: 'test'})
})

app.use(router)

app.listen(5174)

このときのHTMLは以下のようになっている。

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="traceparent" content="<!--traceparent-->" />
    <title>Sample Trace</title>
  </head>
  <body>
    Hello!
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

クライアントで利用している @opentelemetry/instrumentation-document-load は、<meta name="traceparent"> タグによるコンテキスト伝搬をサポートしている。

これにより、初回レンダリング時のサーバ上の処理とクライアント側のローディング処理それぞれのトレースに親子関係を表現できる。

サーバとクライアントのトレースが親子関係をもつ状態で表示されている

計装モジュールを提供している @opentelemetry/auto-instrumentations-node には、たとえばMySQLやPostgreSQLの計装モジュールも含まれているので、おそらくこれらのデータベースに対するクエリについてもトレースできるのではないか (まだ試していない)