トレースデータをメトリックとして Mackerel に送信する

OpenTelemetry コレクターにおけるコネクターは異なるパイプラインを接続する役割を担います。コネクターはパイプラインのレシーバーとエクスポーターとして動作します。

OpenTelemetry には、メトリック、トレース、ログの 3 つの形式があり、これらの形式はそれぞれ別のパイプラインで処理されます。ある形式のデータを別の形式に変換し、あたかも 1 つのパイプライン上でデータを処理したい場合にコネクターを使用します。例えば、あるコネクターはトレースのパイプラインのエクスポーターとメトリックのパイプラインのレシーバーとして動作します。このような場合、コネクターはトレースをメトリックに変換して受け渡す役割を担います。

OTLP から Receivers に線が引かれている。Receivers からは Batch, ..., Attributes または Batch, ..., Filter の経路で Exporters に線が引かれている。Receivers と Exporters の間には Extensions: health, pprof, zpages と Processors がある。Exporters からは OTLP, Jaeger, Prometheus に線が引かれている。
引用元: https://opentelemetry.io/docs/collector/

この記事では Exceptions Connector を使用して、トレーシングのスパンに紐付けられたアプリケーションの例外 をメトリックに変換し Mackerel に送信する方法をご紹介します。

OpenTelemetry におけるトレースの概要

例として、Node.js のアプリケーションにトレーシングを導入してみましょう。OpenTelemetry においてトレースを生成するには、以下の 2 つの方法があります。

  • 自動計装:アプリケーションのコードを変更することなくトレースを生成する
  • 手動計装:トレーシングを行いたい箇所に自分でコードを追加する

多くの言語やフレームワークでは、自動計装を行うためのライブラリが提供されています。例えば Node.js では opentelemetry/sdk-node というライブラリをセットアップするだけで計装できます。この方法は既存のアプリケーションに変更を加えずに容易に導入できるので、トレーシングを導入する最初のステップとして最適です。

手動計装は、開発者自身の手によってアプリケーションコードを修正してトレーシングを行う方法です。スパンに追加の属性を設定したり、新しいスパンを生成したりといった、自動計装では手が届かない詳細なカスタマイズを行いたい場合に使用します。また、学習目的として実際のトレーシングが行われているときに何が発生しているのかを確認するために、すべてを手動計装として実装してみるのもよいでしょう。

今回は手動計装を行う方法を紹介します。まずはトレーシングを行うために必要なパッケージをインストールします。

npm install @opentelemetry/api @opentelemetry/tracing @opentelemetry/instrumentation @opentelemetry/resources @opentelemetry/semantic-conventions @opentelemetry/exporter-trace-otlp-grpc

tracer.js という名前でファイルを作成して、以下のコードを記述します。

import { NodeTracerProvider } from "@opentelemetry/node";
import { registerInstrumentations } from "@opentelemetry/instrumentation";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
import {
  diag,
  DiagConsoleLogger,
  DiagLogLevel,
  trace,
} from "@opentelemetry/api";
import { SimpleSpanProcessor } from "@opentelemetry/tracing";
import { Resource } from "@opentelemetry/resources";
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";

// OpenTelemetry に関するデバッグログを有効にする
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);

// TracerProvider を初期化する
const provider = new NodeTracerProvider({
  // Resource はアプリケーションが実行される環境の属性の集合を表す
  resource: new Resource({
    // ここではサービス名を指定している
    // サービス名により、どのアプリケーションからのトレースなのかを識別できる
    [SemanticResourceAttributes.SERVICE_NAME]: "express-app",
  }),
});

registerInstrumentations({
  tracerProvider: provider,
});

// エクスポーターを設定
// エクスポーターはトレースデータをどこに送信するかを決定する
// OTLPTraceExporter は OpenTelemetry Protocol でデータを送信するエクスポーター
// OpenTelemetry コレクターにデータを送信する
const exporter = new OTLPTraceExporter();
// スパンがどのように処理されるかどうかの設定
// エクスポーターによりスパンが外部へ送信される
provider.addSpanProcessor(new SimpleSpanProcessor(exporter));

// TracerProvider を登録する
provider.register();

// tracer を取得する
export const tracer = trace.getTracer("express-app");

OpenTelemetry におけるトレースは、以下の 3 つのコンポーネントから構成されています。

  • TracerProvider:トレースのエントリーポイント
  • Tracer:スパンを生成する
  • Span:トレース内の 1 つのオペレーション

TracerProvider

Trace Provider はトレース API のエントリーポイントです。ステートフルなオブジェクトとして、設定を保持するのが役割となります。provider.getTracer() メソッドを呼び出すことで Tracer を生成します。

Tracer

Tracer は Span を生成するためのオブジェクトです。tracer.startSpan() メソッドを呼び出すことで Span を開始します。

Span

Span はトレース内の 1 つのオペレーションを表すオブジェクトです。Span は以下のような構造を持ちます。

{
  attributes: {
    'http.url': 'http://localhost:3000/',
    'http.host': 'localhost:3000',
    'net.host.name': 'localhost',
    'http.method': 'GET',
    'http.scheme': 'http',
    'http.target': '/',
  },
  links: [],
  events: [],
  status: { code: 0 },
  endTime: [ 1694320903, 7013875 ],
  _duration: [ 1, 15013875 ],
  name: 'GET',
  context: {
    trace_id: "0x5b8aa5a2d2c872e8321cf37308d69df2",
    span_id: "0x051581bf3cb55c13"
  },
  parentSpanId: undefined,
  kind: 1,
  startTime: [ 1694320901, 992000000 ],
  resource: Resource {
    _attributes: [Object],
    asyncAttributesPending: false,
    _syncAttributes: [Object],
    _asyncAttributesPromise: [Promise]
  },
}

スパンには traceIdparentId が含まれています。traceId とは一連のクライアント - サーバー間のリクエストを識別するための ID です。同じ traceId を持つスパンは同じトレースに属しているとみなされます。parentId はそのエンドポイントを呼び出したスパンの ID です。parentIdundefined の場合には、そのスパンがトレースのルートスパンであることを表します。

また、スパンには通常属性やイベントが含まれており、オペレーションに関する情報を伝えることができます。

Node.js アプリケーションをトレーシングする

それでは、先ほど作成した tracer.js を使用して Node.js アプリケーションをトレーシングしてみましょう。例として、Express を使用したアプリケーションを作成します。

npm install express

server.js という名前でファイルを作成して、以下のコードを記述します。

import express from "express";
import { tracer } from "./tracer.js";
import {
  ROOT_CONTEXT,
  context,
  trace,
  SpanKind,
  SpanStatusCode,
} from "@opentelemetry/api";
import { SemanticAttributes } from "@opentelemetry/semantic-conventions";
const app = express();

app.get("/", async (req, res) => {
  // トレースを開始する
  const span = tracer.startSpan("GET /", {
    kind: SpanKind.SERVER,
  });

  // スパンに属性を設定する
  span.setAttribute(SemanticAttributes.HTTP_METHOD, "GET");
  span.setAttribute(SemanticAttributes.HTTP_URL, "http://localhost:3000/");
  span.setAttribute(SemanticAttributes.HTTP_TARGET, "/");
  span.setAttribute(SemanticAttributes.HTTP_SCHEME, "http");
  span.setAttribute(SemanticAttributes.HTTP_HOST, "localhost:3000");
  try {
    const result = await fetch("http://localhost:3001/price");
    const json = await result.json();

    if (!result.ok) {
      throw new Error(json.message);
    }

    span.setStatus({ code: SpanStatusCode.OK });
    res.json({ message: "Hello World!", price: json.price });
  } catch (e) {
    // スパンに例外を設定する
    span.recordException(e);
    span.setStatus({ code: SpanStatusCode.ERROR });
    res.status(500).json({ message: e.message });
  } finally {
    // スパンを終了する
    span.end();
  }
});

app.listen(3000, () => {
  console.log("Server is running on port 3000");
});

/ ルートにリクエストが送信されたときに、tracer.startSpan() メソッドでトレーシングを開始しています。第 1 引数でスパンの名前を設定しています。続いて、span.setAttribute() でスパンに対する属性を指定しています。HTTP や データベースなど、よく使われる属性のキー名は Trace Semantic Conventions で定義されており、この規則に従うことが推奨されています。そうすることでキー名を統一することができ、複数の環境から収集されたテレメトリーデータを関連付けしやすくなります。

このエンドポイントでは http://localhost:3001/price というエンドポイントにリクエストを送信しています。このリクエストに対するレスポンスが 200 以外の場合には、例外を投げています。catch 句で行っている span.recordException(e) が今回の記事の主題となるスパンに例外を設定する処理です。span API が提供する recordException メソッドを呼び出すことで、例外が Event としてスパンに記録されます。

この記録された例外が、後ほど OpenTelemetry コレクターの Exceptions Connector によりメトリックに変換され Mackerel に送信されます。

ここまでコードを記述したら、以下のコマンドでアプリケーションを起動します。

node server.js

試しに http://localhost:3000/ にリクエストを送信してみましょう。以下のようなログが出力されます。

items to be sent [
  Span {
    attributes: {
      'http.method': 'GET',
      'http.url': 'http://localhost:3000/',
      'http.target': '/',
      'http.scheme': 'http',
      'http.host': 'localhost:3000'
    },
    links: [],
    events: [ [Object], [Object] ],
    status: { code: 2 },
    endTime: [ 1694327822, 192952075 ],
    _ended: true,
    _duration: [ 0, 17833209 ],
    name: 'GET /',
    _spanContext: {
      traceId: 'ab0fdc95c2e142508191cfd0870ffb88',
      spanId: 'a0578afe41aae614',
      traceFlags: 1,
      traceState: undefined
    },
    parentSpanId: undefined,
    kind: 1,
    startTime: [ 1694327822, 175118866 ],
    resource: Resource { attributes: [Object] },
    instrumentationLibrary: { name: '', version: undefined },
    _spanLimits: {
      attributeCountLimit: 128,
      linkCountLimit: 128,
      eventCountLimit: 128
    },
    _spanProcessor: MultiSpanProcessor { _spanProcessors: [Array] }
  }
]
Service request {
  resourceSpans: [ { resource: [Object], scopeSpans: [Array], schemaUrl: undefined } ]
}
{"stack":"Error: 14 UNAVAILABLE: No connection established\n 

items to sent の後に出力されているのが、エクスポーターにより外部へ送信される予定のスパンです。実際にアプリケーションコード内で設定した属性や名前が出力されていることが確認できます。

その後のログでは、{"stack":"Error: 14 UNAVAILABLE: No connection established\n というエラーが出力されています。これは、テレメトリーデータを送信するための OpenTelemetry コレクターをまだ起動していないのが原因です。続いて、OpenTelemetry コレクターを起動していきます。

Exceptions Connector を同梱したカスタム OpenTelemetry コレクターを作成する

Exceptions Connector とは、トレースのスパンに紐付けられた例外をメトリックまたはログに変換するためのコネクターーです。例外が発生した回数を exceptions という名前のメトリックとして Mackerel に送信できます。

Exceptions Connector は、2023 年 9 月現在 OpenTelemetry Collector Contrib には含まれていません。そのため、Exceptions Connector を使用したい場合には、自身で OpenTelemetry コレクターをビルドする必要があります。

ビルダーをインストールする

まずは ocb(OpenTelemetry Collector Builder)というツールをインストールします。

go install go.opentelemetry.io/collector/cmd/builder@latest

インストールが完了したら、以下のコマンドで確認しましょう。

builder version
ocb version dev

マニフェストファイルを作成する

マニフェストファイルは、ビルダーに対してどのコンポーネントを含めるかを指定するためのファイルです。manifest.yaml という名前で以下の内容を保存してください。

dist:
  name: otelcol-dev
  description: Basic OTel Collector distribution for Developers
  output_path: ./otelcol-dev
  otelcol_version: 0.84.0

exporters:
  - gomod: go.opentelemetry.io/collector/exporter/otlpexporter v0.84.0

receivers:
  - gomod: go.opentelemetry.io/collector/receiver/otlpreceiver v0.84.0

connectors:
  - gomod: github.com/open-telemetry/opentelemetry-collector-contrib/connector/exceptionsconnector v0.84.0

exporters, processors, receivers, connectors には、それぞれビルダーに含めるコンポーネントを指定します。dist には、ビルダーが生成するバイナリの名前やバージョンを指定します。ここでは、以下の 3 つのコンポーネントを指定しています。

  • OTLP Exporter:Mackerel に OTLP でデータを送信するエクスポーター
  • OTLP Receiver:Node.js アプリケーションから送信されたデータを OTLP で受け取るレシーバー
  • Exceptions Connector:スパンに紐付けられた例外をメトリックに変換するコネクター

ビルド

マニフェストファイルを作成したら、以下のコマンドでビルドを実行します。

builder --config manifest.yaml

ビルドが完了すると、./otelcol-dev という名前のディレクトリが生成されます。

OpenTelemetry コレクターの設定

OpenTelemetry コレクターの設定ファイルを作成します。otel-collector^config.yaml という名前でファイルを作成し、先ほど用意した ./otelcol-dev ディレクトリに保存します。

receivers:
  otlp:
    protocols:
      grpc:

connectors:
  exceptions:

exporters:
  otlp/mackerel:
    endpoint: otlp.mackerelio.com:4317
    compression: gzip
    headers:
      Mackerel-Api-Key: ${env:MACKEREL_APIKEY}

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [exceptions]
    metrics:
      receivers: [exceptions, otlp]
      exporters: [otlp/mackerel]

receivers には OpenTelemetry Collector が受け取るデータの形式を設定します。ここでは otlp レシーバー を使用して、OTLP で送信されるデータを受け取るように設定しています。先ほどのアプリケーションでは、 OTLPLogExporter を使用して OTLP でデータを送信するように設定していました。

exporters として otlp/mackerel を設定し、メトリックが Mackerel に送信されるための設定を行っています。エンドポイントは otlp.mackerelio.com:4317 で固定となります。headers/Mackerel-Api-Key にはメトリックを送信したい Mackerel のオーガニゼーションの API キーを設定してください。この API キーには書き込み権限が必要です。

connectors が今回の構成の肝です。ここでは Exceptions Connector を使用しています。これは、トレース形式のデータを受け取り、その中で例外に関連するスパンをメトリックに変換して別のパイプラインに受け渡すために使用します。

service にはパイプラインの設定を記述します。ここでは traces と metrics の 2 つのパイプラインを設定しています。冒頭でも説明したとおり、コネクター を使用して traces と metrics の 2 つのパイプラインを接続します。これにより、最終的にメトリックの形式として Mackerel に送信されます。

以下のコマンドで OpenTelemetry Collector を起動します。

cd otelcol-dev
go run . --config otelcol-dev/otel-collector-config.yaml

Mackerel でメトリックを確認する

起動が完了したら、http://localhost:3000 にいくつかリクエストを送信してみてください。http://localhost:3001/price エンドポイントは用意していないので、例外が発生しているはずです。

Mackerel のダッシュボードで例外がメトリックとして収集されていることを確認してみましょう。以下の URL からカスタムダッシュボードの作成画面に移動します。

https://mackerel.io/my/dashboards/-/new#period=30m

グラフウィジェットを選択して、「グラフのタイプ」で「クエリグラフ」を選択します。PromQL を記述するエディタが表示されるので、exceptions{service.name="express-app"} というクエリを入力します。exceptions は Exceptions Connector により生成されたメトリックの名前です。service.name="express-app"} という絞り込み条件を指定することで、先ほど作成した Node.js アプリケーションのメトリックのみを表示できます。

Mackerel のグラフウィジェットのスクリーンショット。クエリには exceptions{service.name="express-app"} と入力されている。その隣にはグラフが描画されている。

まとめ

今回の内容をまとめます。

  • OpenTelemetry コレクターのコネクターは、異なるパイプラインを接続するための機能
  • 1 つのパイプラインでは、コネクターをエクスポーターとして、もう 1 のパイプラインで同じコネクターをレシーバーとして利用することで、データを別のパイプラインに流すことができる
  • 例外に関連するスパンをメトリックに変換するためには、Exceptions Connector を使用する
  • span.recordException(e) でスパンに例外を設定できる

「ラベル付きメトリック機能」ベータ版テストの参加者募集中

現在、「ラベル付きメトリック機能」ベータ版テストの参加者を募集しています。下記のフォームよりベータ版テストへの参加お申し込みをいただけます。

forms.gle