OpenTelemetryは何を送信しているのか

はじめに

OpenTelemetryは、計装を通じてオブザーバビリティを向上させるためのツールです。

OpenTelemetryで計装を行うと、収集したデータはOpenTelemetry Collectorやバックエンド(MackerelやJaeger)に送信されます。
送信されるメトリックやトレースなどのデータは、まとめてシグナルと呼ばれます。

OpenTelemetryの計装は簡単ですが、シグナルを深く理解することで、OpenTelemetryをより効果的に活用できるようになります。
この記事では、Mackerelチームでアプリケーションエンジニアをしている id:mrasu が、OpenTelemetryのシグナルについてProtocol Buffersの定義と計装を深堀りしていきます。

シグナルの送信

OpenTelemetryでアプリケーションを計装すると、シグナルが送信されます。
シグナルはOTLP(OpenTelemetry Protocol)という仕様に従ってOpenTelemetry Collectorやバックエンドに送信されます。

たとえば、Goでトレースを追加するコードは以下のようになります。

func MySpanFunction(ctx context.Context) {
  ctx, span := tracer.Start(ctx, "mySpan")
  defer span.End()
  
  ...
}

また、メトリックを追加する場合は以下のようになります。

var myCounter, err = meter.Int64Counter("my.counter")

func MyMetricFunction(ctx context.Context) {
  myCounter.Add(ctx, 1)

  ...
}

OTLPはgRPCとHTTPをサポートしており、送信するデータはProtocol Buffers(protobuf)で定義されています。
各言語のSDKは、このprotobufの定義をほとんどそのまま利用しているため、protobufの構造を理解することで、どの言語での計装にも役立ちます。

Protocol Buffersでの定義

OpenTelemetryでは、シグナルが送信される際に、データのフォーマットとしてProtocol Buffers(protobuf)が使用されます。
protobufの定義は、opentelemetry-protoリポジトリの中で、シグナルごとに定義されています。
たとえば、v1.5.0時点では、トレースはtrace.proto、メトリックはmetrics.protoというファイルで定義されています。

これらのprotobufの定義を参照することで、OpenTelemetryが送信しているデータの内容を理解できます。

たとえば、トレースは以下のように、TracesDataを最上位のメッセージとして、ResourceSpans、ScopeSpans、Spanと、ネストされた構造で定義されています。

message TracesData {
  repeated ResourceSpans resource_spans = 1;
}

message ResourceSpans {
  opentelemetry.proto.resource.v1.Resource resource = 1;
  repeated ScopeSpans scope_spans = 2;
  ...
}

message ScopeSpans {
  opentelemetry.proto.common.v1.InstrumentationScope scope = 1;
  repeated Span spans = 2;
  ...
}

message Span {
  bytes trace_id = 1;
  bytes span_id = 2;
  string name = 5;
  ...
}

このように、Spanにはtrace_idやspan_id、nameなどトレースとスパンに関する中心的な情報が含まれていることがわかります。

また、メトリックはMetricsDataを最上位として、ResourceMetrics、ScopeMetrics、Metricと定義されていて、さらにMetricの中にゲージやヒストグラムの値を持つdataフィールドが含まれています。

message MetricsData {
  repeated ResourceMetrics resource_metrics = 1;
}

message ResourceMetrics {
  opentelemetry.proto.resource.v1.Resource resource = 1;
  repeated ScopeMetrics scope_metrics = 2;
  ...
}

message ScopeMetrics {
  opentelemetry.proto.common.v1.InstrumentationScope scope = 1;
  repeated Metric metrics = 2;
  ...
}
message Metric {
  string name = 1;
  oneof data {
    Gauge gauge = 5;
    Sum sum = 7;
    Histogram histogram = 9;
    ExponentialHistogram exponential_histogram = 10;
    Summary summary = 11;
  }
  ...
}

message Gauge {
  repeated NumberDataPoint data_points = 1;
}

message NumberDataPoint {
  oneof value {
    double as_double = 4;
    sfixed64 as_int = 6;
  }
  ...
}

これらのprotobufの定義を理解することで、OpenTelemetryをより効果的に利用できるようになります。

次節では、これらの定義と計装の関係を詳しく見ていきます。

Protocol Buffersと計装

前節では、Protocol Buffers(protobuf)でシグナルの内容が定義されていることを見てきました。
この節では、protobufの定義を実際の計装にどのように活用するかを見ていきます。

トレースの定義と計装

前節で紹介したように、トレースはTracesDataを最上位としてResourceSpans、ScopeSpans、Spanと、ネストされた構造で定義されています。

Mackerelの画面からもわかるように、トレースはスパンの集合なので、protobufのSpanの部分には多くの情報が含まれています。

Mackerelでのトレース画面

attributesフィールド

Spanには、トレースに関する詳細な情報を含むさまざまなフィールドがあります。
たとえばattributesというフィールドでは、スパンのメタデータをkey-valueで記録できます。
attributesのキー名に制限はありませんが、Semantic ConventionsにHTTPやDBなど、よく使われるキーが定義されているので、これに従うと開発者間でコミュニケーションがしやすくなるでしょう。

たとえば、enduser.idというキーが定義されています。
これはユーザーIDを記録するときに利用するもので、Goでは以下のように書きます。

func MySpanFunction(ctx context.Context) {
  ctx, span := tracer.Start(ctx, "mySpan")
  defer span.End()
  
  // attributesにユーザーIDを設定する
  span.SetAttributes(attribute.String("enduser.id", "386ec0250009b3752a6d9c1e"))
  
  ...
}

これで、Spanのattributesフィールドに値が設定されます。protobufの内容をjsonで表現すると、以下のようになります。

{
  "spanId": "xxxx",
  ...

  // attributesに設定される
  "attributes": [
    {"key": "enduser.id", "value": "386ec0250009b3752a6d9c1e"}
  ]
}

フィールドの役割と計装

attributes以外にも、Spanには多くの設定ができます。
Spanの定義を見ると、以下の項目があることがわかります。

message Span {
  bytes trace_id = 1;
  bytes span_id = 2;
  string trace_state = 3;
  bytes parent_span_id = 4;
  fixed32 flags = 16;
  string name = 5;
  SpanKind kind = 6;
  fixed64 start_time_unix_nano = 7;
  fixed64 end_time_unix_nano = 8;
  repeated KeyValue attributes = 9;
  uint32 dropped_attributes_count = 10;
  repeated Event events = 11;
  uint32 dropped_events_count = 12;
  repeated Link links = 13;
  uint32 dropped_links_count = 14;
  Status status = 15;
}

これらのフィールドはそれぞれ意味を持っていますが、この中から主要なものを3つ挙げて説明します。

1. kind

スパンが作成されたコンテキストを表すフィールドです。
値はenumで、SERVER・CLIENT・PRODUCER・CONSUMERなどがあります。
たとえば、HTTPリクエストを受信したときにはSERVERと設定し、送信したときにはCLIENTと設定することで、そのスパンが受信と送信どちらかを区別できるようになります。

Goでは、tracer.Start(ctx, "mySpan", trace.WithSpanKind(trace.SpanKindClient))のように書いて設定できます。

2. status

スパンに紐づく処理が成功したのか、あるいは失敗したのかを記録するフィールドです。
たとえば、HTTPリクエストが失敗したときに使用します。

Goでは、span.SetStatus(codes.Error, "request failed")のように設定します。

3. events

スパンの実行中に発生したイベントを記録するフィールドです。
たとえば、エラーが発生したときや、課金が発生したときに、イベントの情報を記録できます。
ログとの役割分担が不明瞭であるため仕様変更の議論が上がる問題児ですが、スパンの中に細かい情報を含めることができる便利なフィールドです。

Goであれば、以下のようにイベントを追加することができます。

span.AddEvent(
  "charged",
  trace.WithAttributes(
    attribute.String("enduser.id", "386ec0250009b3752a6d9c1e"),
    attribute.Int("custom.charge.amount", 10000),
  ),
  trace.WithTimestamp(time.Now()),
)

設定した内容をjsonで表現すると下のようになります。

{
  "spanId": "xxxx",
  ...

  // eventsに追加される
  "events": [
    {
      "name": "charged",
      "attributes": [
        {"key": "enduser.id", "value": "386ec0250009b3752a6d9c1e"},
        {"key": "custom.charge.amount", "value": 10000},
      ],
      "time_unix_nano": 981126000000000000,
    }
  ]
}

resourceとscopeの計装

さらに、Spanの親に目を向けると、ScopeSpansにあるscope、その親であるResourceSpansにはresourceがあります。これらにも情報を追加できます。

resourceはマシンやノードなど、エンティティを表すフィールドです。
たとえばAWS環境であればインスタンスの情報、KubernetesであればPodの情報などを入れるフィールドです。
Spanの情報はスパンを作成するときに設定しましたが、resourceは環境変数かTracerProviderで設定します。

Goでは、以下のように書くと設定できます。

trace.NewTracerProvider(
  trace.WithResource(resource.NewSchemaless(
    attribute.String("k8s.pod.name", "dice-server"),
    attribute.String("k8s.pod.uid", "397c7549-c946-4891-bce5-c3ac6b5a6a02"),
    attribute.String("os.name", "Ubuntu"),
  )),
)

設定した内容をjsonで表現すると以下のようになります。

{
  // resourceの内容が設定される
  "resource": {
    "attributes": [
      {"key": "k8s.pod.name", "value": "dice-server"},
      {"key": "k8s.pod.uid", "value": "397c7549-c946-4891-bce5-c3ac6b5a6a02"},
      {"key": "os.name", "value": "Ubuntu"},
    ]
  },
  "scope_spans": [
    {
      "spans": [...]
    }
  ]
}

ただ、すべての情報を手で書くのは手間がかかるので、AWSの情報などはライブラリに任せると簡単です。
たとえば、以下のようにec2パッケージ(go.opentelemetry.io/contrib/detectors/aws/ec2)を使用するとEC2の情報が自動で収集され、resourceに設定されます。

res, _ := resource.New(
  context.Background(),
  resource.WithDetectors(
    ec2.NewResourceDetector(),
  ),
)
res, _ = resource.Merge(resource.Default(), res)
trace.NewTracerProvider(sdktrace.WithResource(res))

また、scopeにはTracerの情報が入っています。
これにより、計装されたライブラリの名前やバージョンを知ることができます。

var tracer := otel.Tracer(
  "example.com/foo/bar/MyTracer", 
  trace.WithInstrumentationVersion("v1.1"), 
)

設定した内容をjsonで表現すると、以下のようになります。

{
  "resource": {...},
  "scope_spans": [
    {
      // scopeの内容が設定される
      "scope": {
        "name": "example.com/foo/bar/MyTracer",
        "version": "v1.1"
      },
      "spans": [...]
    }
  ]
}

メトリックの定義と計装

トレースの次は、メトリックを見ていきます。

前節で、メトリックはMetricsDataを最上位のメッセージとして、 ResourceMetrics、ScopeMetrics、Metricと、ネストされていることを紹介しました。

Metricのprotobufと計装

Metricのdataフィールドにはゲージ(Gauge)やヒストグラム(Histogram)など、メトリックの種類に応じた専用のDataPointが入っています。

たとえば、ゲージを使用するコードをGoで書くと以下のようになります。

var myGauge, err = meter.Int64Gauge("my.gauge")

func MyMetricFunction(ctx context.Context) {
  myGauge.Record(ctx, 123)

  ...
}

このメトリックの内容のprotobufをjsonで表現すると以下のようになります。

{
  "name": "my.gauge",
  "data": {
    "data_points": [
      {
        "start_time_unix_nano": 981126000000000000,
        "time_unix_nano": 981126000000000000,
        "value": 123,
      },
    ]
  }
}

このように、メトリックはDataPointに記録される内容が重要な要素となります。

attributesフィールドの役割と計装

メトリックに関して最も大きなトピックは、種類(ゲージ、ヒストグラム、カウンターなど)の違いですが、その話は以下の記事のように色々な場所で詳しく書かれているので、ここでは触れません。 blog.rmatsuoka.org

この記事では、その他の重要な特徴であるattributesフィールドについて説明します。

attributesはスパンで登場したように、メタデータをkey-valueで記録することができるフィールドです。
異なるattributesを持たせることで、メトリックが複数のデータポイントを持つようになります。
たとえば、エンドポイントごとのレイテンシーを計測する場合に、http.routeキーを設定することで、同一のヒストグラムを用いつつ、各エンドポイントの値を区別して記録することができるようになります。

たとえば、/user/:id/user/:id/order/:idのレイテンシーを区別したメトリックをGoで書くと、以下のようなコードになります。

var httpDuration, _ = meter.Float64Histogram(
  "http.server.request.duration",
  metric.WithUnit("s"),
)

func handleUser(w http.ResponseWriter, r *http.Request) {
  start := time.Now()
  // HTTPリクエストの処理
  ...

  httpDuration.Record(
    r.Context(), 
    float64(time.Since(start).Milliseconds()) / 1000.0,
    metric.WithAttributes(
      attribute.String("http.route", "/user/:id"),
    ),
  )
}

func handleUserOrder(w http.ResponseWriter, r *http.Request) {
  start := time.Now()
  // HTTPリクエストの処理
  ...

  httpDuration.Record(
    r.Context(), 
    float64(time.Since(start).Milliseconds()) / 1000.0,
    metric.WithAttributes(
      attribute.String("http.route", "/user/:id/order/:id"),
    ),
  )
}

このコードでは、http.routeのattributeを使用して、/user/:id/user/:id/order/:idそれぞれのレイテンシーを記録しています。

このコードを使ってできるprotobufをjsonで表現すると、以下のようになります。

{
  "name": "http.server.request.duration",
  "unit": "s",
  "data": {
    "data_points": [
      {
        "attributes": [
          {"key": "http.route", "value": "/user/:id"}
        ],
        "start_time_unix_nano": 981126000000000000,
        "time_unix_nano": 981126000000000000,
        "count": 10,
        "bucket_counts": [0,1,2,3,4,0,0,0,0,0,0,0],
        "explicit_bounds": [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10],
      },
      {
        "attributes": [
          {"key": "http.route", "value": "/user/:id/order/:id"}
        ],
        "start_time_unix_nano": 981126000000000000,
        "time_unix_nano": 981126000000000000,
        "count": 14,
        "bucket_counts": [0,0,2,4,8,0,0,0,0,0,0,0],
        "explicit_bounds": [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10],
      }
    ]
  }
}

data_pointsが複数になっていることから、同一のメトリック名で各エンドポイントの値をそれぞれ記録できていることがわかります。
しかし、一方で、data_pointsが増えると送信されるデータ量も増加します。
attributesは非常に便利ですが、無節操に増やしてしまうとデータ量が膨らんでしまうため、注意が必要なフィールドでもあります。

resourceとscopeの役割

Metricの親にはScopeMetricsとResourceMetricsが存在します。
これらはSpanの場合と同様の構造を持っています。
それぞれScopeSpansとResourceSpansと同様にscoperesourceフィールドがあります。

それぞれの設定方法や役割もSpanのものと同じです。

まとめ

この記事では、シグナルにおけるトレースとメトリックのprotobuf定義を見てきました。
コード例からもわかるように、OpenTelemetryのSDKはprotobufの定義をほぼそのまま利用しています。
また、例に使用したGo以外の言語のSDKであっても関数名やインターフェースは多くが共通しているため、protobufの内容を理解することが、SDKを最大限に活用するための鍵となります。

本記事が、OpenTelemetryをより深く理解し、効果的に活用する一助となれば幸いです。

以上、OpenTelemetryが送信しているprotobufに関する解説でした。