OpenTelemetry Collector でアプリの Metric/Trace/Log を Grafana に連携する

# はじめに
## 概要
この記事では OpenTelemetry Collector を用いてアプリケーションの Metric/Trace/Log を Grafana に連携する方法を紹介します。
尚 OpenTelemetry の説明については以下のドキュメントを参照してください。

- https://opentelemetry.io/docs/specs/otel/overview/

今回は以下の構成を docker compose を利用して構築したいと思います。

![otelcol.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/609343/0b41cfe5-e9a5-2040-0687-b2030dfc94bd.png)

- App … Golang で実装したアプリケーションになります([参考](https://opentelemetry.io/docs/languages/go/getting-started/))。送信は SDK を使って OTLPHTTP で行います
- OpenTelemetry Collector … Metric/Trace/Log を App から受け取り各コンポーネントに送信します
- Loki/Tempo/Mimir/Grafana … Grafana とそれに付随するシステムです、詳細は公式のドキュメントを確認してください

## 動機
OpenTelemetry SDK を用いると OTLP 形式で Metric/Trace/Log を送信することが可能で、OpenTelemetry Collector と Grafana の各コンポーネント(loki/tempo)は OTLP に対応しています。このことから OpenTelemetry SDK と Collector のみで Metric/Trace/Log の送信も可能ということになります。

しかし、現状 OpenTelemetry SDK を用いてアプリの Metric/Trace/Log を収集する記事は多くありますが、収集の際に OpenTelemetry Collector のみで送信しているものがあまりありませんでした。そこで、収集を複数ツールで行う際に発生する構成管理の複雑化を OpenTelemetry Collector に統一することで回避できるのではないかと考え、検証を行い記事にしようと考えました。

合わせて再現難易度を下げるため docker compose を用いて構築するようにしています。

## 注意事項
今回はあくまで検証目的での構築になります。  
そのため以下の内容は考慮していません。  

- データの永続化
- 耐障害性
- 認証

実際に運用する場合は、上記も考慮して構成してください。

# 手順
それでは、構成を作成していきましょう。  
流れとしては「事前準備 → App作成 → compose.yaml と設定ファイル作成 → 動かす」で完了します。

## 事前準備
docker 以外に以下のものは既にインストールされている想定で初めていきます。

- [ko](https://ko.build/) … golang のアプリケーションの docker image が作成しやすくなります

## App作成
golang のアプリケーションを作成していきます。  
基本的には OpenTelemetry の公式の [Getting Started](https://opentelemetry.io/docs/languages/go/getting-started/) で紹介されているもので、エンドポイントを叩かれたら出目を返すアプリです。変更点としては以下になります。  

- otel.go … stdout に流しているものを otlphttp で流しています
- rolldice.go … metric と trace にdiceを振った人の名前も記録するようにしています

```bash
# module作成
mkdir dice
cd dice
go mod init dice

# 必要なライブラリ取得
go get "go.opentelemetry.io/otel" \
  "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" \
  "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" \
  "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" \
  "go.opentelemetry.io/otel/sdk/log" \
  "go.opentelemetry.io/otel/log/global" \
  "go.opentelemetry.io/otel/propagation" \
  "go.opentelemetry.io/otel/sdk/metric" \
  "go.opentelemetry.io/otel/sdk/resource" \
  "go.opentelemetry.io/otel/sdk/trace" \
  "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"\
  "go.opentelemetry.io/contrib/bridges/otelslog" \
  "go.opentelemetry.io/otel/semconv/v1.26.0"
```

### otel.go
Metric/Trace/Log の Provider の設定をしていきます。  
今回は oltphttp で送信します。

<details><summary>otel.go</summary>

```go
package main

import (
	"context"
	"errors"
	"time"

	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
	"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
	"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
	"go.opentelemetry.io/otel/log/global"
	"go.opentelemetry.io/otel/propagation"
	"go.opentelemetry.io/otel/sdk/log"
	"go.opentelemetry.io/otel/sdk/metric"
	"go.opentelemetry.io/otel/sdk/resource"
	"go.opentelemetry.io/otel/sdk/trace"
	semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)

func setupOTelSDK(ctx context.Context) (shutdown func(context.Context) error, err error) {
	var shutdownFuncs []func(context.Context) error

	shutdown = func(ctx context.Context) error {
		var err error
		for _, fn := range shutdownFuncs {
			err = errors.Join(err, fn(ctx))
		}
		shutdownFuncs = nil
		return err
	}

	handleErr := func(inErr error) {
		err = errors.Join(inErr, shutdown(ctx))
	}

	prop := newPropagator()
	otel.SetTextMapPropagator(prop)

	res, err := newResource()
	if err != nil {
		handleErr(err)
		return
	}

	tracerProvider, err := newTraceProvider(ctx, res)
	if err != nil {
		handleErr(err)
		return
	}
	shutdownFuncs = append(shutdownFuncs, tracerProvider.Shutdown)
	otel.SetTracerProvider(tracerProvider)

	meterProvider, err := newMeterProvider(ctx, res)
	if err != nil {
		handleErr(err)
		return
	}
	shutdownFuncs = append(shutdownFuncs, meterProvider.Shutdown)
	otel.SetMeterProvider(meterProvider)

	loggerProvider, err := newLoggerProvider(ctx, res)
	if err != nil {
		handleErr(err)
		return
	}
	shutdownFuncs = append(shutdownFuncs, loggerProvider.Shutdown)
	global.SetLoggerProvider(loggerProvider)µ

	return
}

func newPropagator() propagation.TextMapPropagator {
	return propagation.NewCompositeTextMapPropagator(
		propagation.TraceContext{},
		propagation.Baggage{},
	)
}

func newTraceProvider(ctx context.Context, res *resource.Resource) (*trace.TracerProvider, error) {
	traceExporter, err := otlptracehttp.New(ctx)
	if err != nil {
		return nil, err
	}

	traceProvider := trace.NewTracerProvider(
		trace.WithResource(res),
		trace.WithBatcher(traceExporter,
			trace.WithBatchTimeout(time.Second)),
	)
	return traceProvider, nil
}

func newMeterProvider(ctx context.Context, res *resource.Resource) (*metric.MeterProvider, error) {
	metricExporter, err := otlpmetrichttp.New(ctx)
	if err != nil {
		return nil, err
	}

	meterProvider := metric.NewMeterProvider(
		metric.WithResource(res),
		metric.WithReader(metric.NewPeriodicReader(metricExporter,
			metric.WithInterval(3*time.Second))),
	)
	return meterProvider, nil
}

func newResource() (*resource.Resource, error) {
	return resource.Merge(resource.Default(),
		resource.NewWithAttributes(semconv.SchemaURL,
			semconv.ServiceName("dice"),
			semconv.ServiceVersion("0.1.0"),
		))
}

func newLoggerProvider(ctx context.Context, res *resource.Resource) (*log.LoggerProvider, error) {
	logExporter, err := otlploghttp.New(ctx)
	if err != nil {
		return nil, err
	}

	loggerProvider := log.NewLoggerProvider(
		log.WithResource(res),
		log.WithProcessor(log.NewBatchProcessor(logExporter)),
	)
	return loggerProvider, nil
}
```
</details>


### rolldice.go
実際に dice の目を返す部分です。  
実行したユーザ名とともに Metric/Trace/Log を適宜作成しています。

<details><summary>rolldice.go</summary>

```go
package main

import (
	"fmt"
	"io"
	"log"
	"math/rand"
	"net/http"
	"strconv"

	"go.opentelemetry.io/contrib/bridges/otelslog"
	"go.opentelemetry.io/otel"
	"go.opentelemetry.io/otel/attribute"
	"go.opentelemetry.io/otel/metric"
)

const name = "dice-app"

var (
	tracer  = otel.Tracer(name)
	meter   = otel.Meter(name)
	logger  = otelslog.NewLogger(name)
	rollCnt metric.Int64Counter
)

func init() {
	var err error
	rollCnt, err = meter.Int64Counter("dice.rolls",
		metric.WithDescription("The number of rolls by roll value"),
		metric.WithUnit("{roll}"))
	if err != nil {
		panic(err)
	}
}

func rolldice(w http.ResponseWriter, r *http.Request) {
	ctx, span := tracer.Start(r.Context(), "roll")
	defer span.End()

	roll := 1 + rand.Intn(6)

	var msg string
	var playerName = "anonymous"
	if player := r.PathValue("player"); player != "" {
		msg = fmt.Sprintf("%s is rolling the dice", player)
		playerName = player
	} else {
		msg = "Anonymous player is rolling the dice"
	}
	logger.InfoContext(ctx, msg, "result", roll, "player", playerName)

	if roll > 3 {
		logger.WarnContext(ctx, "Roll is greater than 3", "roll", roll, "player", playerName)
	} else {
		logger.ErrorContext(ctx, "Roll is less than 3", "roll", roll, "player", playerName)
	}

	rollValueAttr := attribute.Int("roll.value", roll)
	playerAttr := attribute.String("player", playerName)
	span.SetAttributes(rollValueAttr)
	span.SetAttributes(playerAttr)
	rollCnt.Add(ctx, 1, metric.WithAttributes(rollValueAttr, playerAttr))

	resp := strconv.Itoa(roll) + "\n"
	if _, err := io.WriteString(w, resp); err != nil {
		log.Printf("Write failed: %v\n", err)
	}
}
```
</details>

### main.go
http の handler の設定などを実施しています。

<details><summary>main.go</summary>

```go
package main

import (
	"context"
	"errors"
	"log"
	"net"
	"net/http"
	"os"
	"os/signal"
	"time"

	"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
)

func main() {
	if err := run(); err != nil {
		log.Fatalln(err)
	}
}

func run() (err error) {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
	defer stop()

	otelShutdown, err := setupOTelSDK(ctx)
	if err != nil {
		return
	}
	defer func() {
		err = errors.Join(err, otelShutdown(context.Background()))
	}()

	srv := &http.Server{
		Addr:         ":8080",
		BaseContext:  func(_ net.Listener) context.Context { return ctx },
		ReadTimeout:  time.Second,
		WriteTimeout: 10 * time.Second,
		Handler:      newHTTPHandler(),
	}
	srvErr := make(chan error, 1)
	go func() {
		srvErr <- srv.ListenAndServe()
	}()

	select {
	case err = <-srvErr:
		return
	case <-ctx.Done():
		stop()
	}

	err = srv.Shutdown(context.Background())
	return
}

func newHTTPHandler() http.Handler {
	mux := http.NewServeMux()

	handleFunc := func(pattern string, handlerFunc func(http.ResponseWriter, *http.Request)) {
		handler := otelhttp.WithRouteTag(pattern, http.HandlerFunc(handlerFunc))
		mux.Handle(pattern, handler)
	}

	handleFunc("/rolldice/", rolldice)
	handleFunc("/rolldice/{player}", rolldice)

	handler := otelhttp.NewHandler(mux, "/")
	return handler
}
```
</details>

### compose.yaml
このタイミングで App を compose.yaml に記載します。

<details><summary>compose.yaml</summary>

```yaml
services:
  # 情報収集用のアプリ
  dice-app:
    image: ko.local/dice:latest
    platform: linux/amd64
    ports:
      - 8080:8080
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
```
</details>

## compose.yaml 修正と設定ファイル作成(Grafana関連)
Loki/Grafana/Tempo/Mimir を構築するための compose.yaml と各設定ファイルを記載します(設定ファイルは `config` ディレクトリ以下に配置)。  
基本的に各コンポーネントのリポジトリの example 以下のままですが、注意点として以下を記載します。  

- mimir … LoadBalancer を nginx で作成しており、 mimir は LB 経由で呼び出されます
- loki … OTLP を受け入れるために latest ではなく明示的に 3.1.0 にしています

### compose.yaml
App について記載したものに追記している前提になります。

<details><summary>compose.yaml</summary>


```yaml
  # grafana
  grafana:
    environment:
      - GF_PATHS_PROVISIONING=/etc/grafana/provisioning
      - GF_AUTH_ANONYMOUS_ENABLED=true
      - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
      - GF_FEATURE_TOGGLES_ENABLE=traceqlEditor traceQLStreaming metricsSummary
    image: grafana/grafana:latest
    volumes:
      - ./config/datasource.yaml:/etc/grafana/provisioning/datasources/datasource.yaml
    ports:
      - 3000:3000

  # mimir関連
  minio:
    image: minio/minio
    entrypoint: [ "" ]
    command: [ "sh", "-c", "mkdir -p /data/mimir && minio server --quiet /data" ]
    environment:
      - MINIO_ROOT_USER=mimir
      - MINIO_ROOT_PASSWORD=supersecret

  mimir-lb:
    image: nginx:latest
    volumes:
      - ./config/nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - "mimir-1"
      - "mimir-2"
      - "mimir-3"
    ports:
      - 9009:9009

  mimir-1:
    image: grafana/mimir:latest
    command: [ "-config.file=/etc/mimir.yaml", "-auth.multitenancy-enabled=false" ]
    hostname: mimir-1
    depends_on:
      - minio
    volumes:
      - ./config/mimir.yaml:/etc/mimir.yaml

  mimir-2:
    image: grafana/mimir:latest
    command: [ "-config.file=/etc/mimir.yaml", "-auth.multitenancy-enabled=false" ]
    hostname: mimir-2
    depends_on:
      - minio
    volumes:
      - ./config/mimir.yaml:/etc/mimir.yaml

  mimir-3:
    image: grafana/mimir:latest
    command: [ "-config.file=/etc/mimir.yaml", "-auth.multitenancy-enabled=false" ]
    hostname: mimir-3
    depends_on:
      - minio
    volumes:
      - ./config/mimir.yaml:/etc/mimir.yaml

  # tempo関連(metric_generatorのためにprometheusもいる)
  tempo:
    image: grafana/tempo:latest
    command: [ "-config.file=/etc/tempo.yaml" ]
    volumes:
      - ./config/tempo.yaml:/etc/tempo.yaml
    ports:
      - "3200:3200"   # tempo
      - "9095:9095" # tempo grpc

  prometheus:
    image: prom/prometheus:latest
    command:
      - --config.file=/etc/prometheus.yaml
      - --web.enable-remote-write-receiver
      - --enable-feature=exemplar-storage
      - --enable-feature=native-histograms
    volumes:
      - ./config/prometheus.yaml:/etc/prometheus.yaml
    ports:
      - "9090:9090"

  # loki
  loki:
    image: grafana/loki:3.1.0
    ports:
      - 3100:3100
    volumes:
      - ./config/loki.yaml:/etc/loki/local-config.yaml
    command: -config.file=/etc/loki/local-config.yaml
```
</details>

### datasource.yaml
grafana の datasource の設定をあらかじめ入れておきます。


<details><summary>datasource.yaml</summary>


```yaml
apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    uid: prometheus
    access: proxy
    orgId: 1
    url: http://prometheus:9090
    basicAuth: false
    isDefault: false
    version: 1
    editable: false
    jsonData:
      httpMethod: GET
  - name: Loki
    type: loki
    access: proxy
    orgId: 1
    url: http://loki:3100
    basicAuth: false
    isDefault: true
    version: 1
    editable: false
  - name: Tempo
    type: tempo
    access: proxy
    orgId: 1
    url: http://tempo:3200
    basicAuth: false
    isDefault: false
    version: 1
    editable: false
    apiVersion: 1
    uid: tempo
    jsonData:
      httpMethod: GET
      serviceMap:
        datasourceUid: prometheus
      lokiSearch:
        datasourceUid: 'loki'
      tracesToLogs:
        datasourceUid: 'loki'
        tags: [ 'container' ]
        mappedTags: [ { key: 'service.name', value: 'dice' } ]
        mapTagNamesEnabled: true
        spanStartTimeShift: '-1h'
        spanEndTimeShift: '1h'
        filterByTraceID: true
        filterBySpanID: false
  - name: Mimir
    type: prometheus
    access: proxy
    orgId: 1
    url: http://mimir-lb:9009/prometheus
    version: 1
    editable: true
    isDefault: false
```
</details>

### loki.yaml
loki の設定ファイルです。

<details><summary>loki.yaml</summary>

```yaml
auth_enabled: false

server:
  http_listen_port: 3100

common:
  instance_addr: 127.0.0.1
  path_prefix: /loki
  storage:
    filesystem:
      chunks_directory: /loki/chunks
      rules_directory: /loki/rules
  replication_factor: 1
  ring:
    kvstore:
      store: inmemory

schema_config:
  configs:
    - from: 2020-10-24
      store: tsdb
      object_store: filesystem
      schema: v13
      index:
        prefix: index_
        period: 24h

ruler:
  alertmanager_url: http://localhost:9093

limits_config:
  allow_structured_metadata: true
```
</details>

### mimir.yaml
mimir の設定ファイルです。

<details><summary>mimir.yaml</summary>

```yaml
# Do not use this configuration in production.
# It is for demonstration purposes only.
# Run Mimir in single process mode, with all components running in 1 process.
target: all,overrides-exporter

# Configure Mimir to use Minio as object storage backend.
common:
  storage:
    backend: s3
    s3:
      endpoint: minio:9000
      access_key_id: mimir
      secret_access_key: supersecret
      insecure: true
      bucket_name: mimir

# Blocks storage requires a prefix when using a common object storage bucket.
blocks_storage:
  storage_prefix: blocks
  tsdb:
    dir: /data/ingester

# Use memberlist, a gossip-based protocol, to enable the 3 Mimir replicas to communicate
memberlist:
  join_members: [mimir-1, mimir-2, mimir-3]

server:
  log_level: warn
```
</details>

### nginx.conf
mimir の LoadBalancer 用の nginx の設定ファイルです。

<details><summary>nginx.conf</summary>

```
events {
    worker_connections 1024;
}

http {
    upstream backend {
        server mimir-1:8080 max_fails=1 fail_timeout=1s;
        server mimir-2:8080 max_fails=1 fail_timeout=1s;
        server mimir-3:8080 max_fails=1 fail_timeout=1s backup;
    }

    server {
        listen 9009;
        access_log /dev/null;
        location / {
            proxy_pass http://backend;
        }
    }
}
```
</details>

### tempo.yaml
tempo の設定ファイルです。

<details><summary>tempo.yaml</summary>

```yaml
stream_over_http_enabled: true
server:
  http_listen_port: 3200
  log_level: info

query_frontend:
  search:
    duration_slo: 5s
    throughput_bytes_slo: 1.073741824e+09
  trace_by_id:
    duration_slo: 5s

distributor:
  receivers:
    otlp:
      protocols:
        http:

metrics_generator:
  storage:
    path: /var/tempo/generator/wal
    remote_write:
      - url: http://prometheus:9090/api/v1/write
        send_exemplars: true
  traces_storage:
    path: /var/tempo/generator/traces

ingester:
  max_block_duration: 5m               # cut the headblock when this much time passes. this is being set for demo purposes and should probably be left alone normally

compactor:
  compaction:
    block_retention: 1h                # overall Tempo trace retention. set for demo purposes

storage:
  trace:
    backend: local                     # backend configuration to use
    wal:
      path: /var/tempo/wal             # where to store the wal locally
    local:
      path: /var/tempo/blocks

overrides:
  defaults:
    metrics_generator:
      processors: [service-graphs, span-metrics, local-blocks] # enables metrics generator
      generate_native_histograms: both
```
</details>

### prometheus.yaml
tempo で利用する prometheus の設定ファイルです。

<details><summary>prometheus.yaml</summary>

```yaml
global:
  scrape_interval:     15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: [ 'localhost:9090' ]
  - job_name: 'tempo'
    static_configs:
      - targets: [ 'tempo:3200' ]
```
</details>

## OpenTelemetry Collector 関係の設定追加
OpenTelemetry Collector を docker compose 上で稼働させるための設定を作成します( otelcol.yaml は `config` ディレクトリ以下に配置)。  
compose.yaml は上記のものに追記している前提になります。

### compose.yaml

<details><summary>compose.yaml</summary>

```yaml
  # opentelemetry collector
  otel-collector:
    image: otel/opentelemetry-collector-contrib
    volumes:
      - ./config/otelcol.yaml:/etc/otelcol-contrib/config.yaml
    ports:
      - 1888:1888 # pprof extension
      - 13133:13133 # health_check extension
      - 55679:55679 # zpages extension
      - 12345:12345 # prometheus
```
</details>

### otelcol.yaml

<details><summary>otelcol.yaml</summary>

```yaml
receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:

exporters:
  debug:
  otlphttp/loki:
    endpoint: http://loki:3100/otlp
  otlphttp/tempo:
    endpoint: http://tempo:4318
  otlphttp/mimir:
    endpoint: http://mimir-lb:9009/otlp

service:
  extensions: [health_check, pprof, zpages]
  pipelines:
    logs:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/loki, debug]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/mimir, debug]
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlphttp/tempo, debug]


extensions:
  health_check:
  pprof:
  zpages:
```
</details>

## 実行
準備はできたので実行します。

```bash
# Appビルド&docker image作成
## ko.local/dice:latest というimageが作成される
ko publish -L --base-import-paths .

# image確認
docker images ko.local/dice:latest

# 起動
docker compose up -d
docker compose ps

# Appにリクエスト投げる
curl localhost:8080/rolldice/
curl localhost:8080/rolldice/alice/
```

## grafana で確認
問題なく起動できたら localhost:3000 にアクセスして grafana に接続してみましょう。  
datasource の設定はできていると思うので、 dashboard の作成が可能になっているかと思います。

# まとめ
今回の検証で以下のことが確認できました。  

- OpenTelemetry SDK で App に作り込むと OpenTelemetry Collector のみで metric/trace/log が収集できる事
- 検証環境程度だったら docker compose で気軽に作成することが可能な事

また OpenTelemetry の SDK および Collector を用いることで以下のメリットと懸念点があると考えています。

- メリット
    - metric/log/trace をアプリから Grafana 等の観測できるアプリケーションに送る際、利用するものが OpenTelemetry に統一される(sdk と collector)ため構築が煩雑にならない
    - OpenTelemetry SDK を使い metric/log/trace を出力するとそれぞれの関連付けが容易になる
- 懸念点
    - OpenTelemetry SDK や Collector には一部 experimantal や beta の機能があるので、今後変更が入る可能性の考慮が必要
    - アプリケーションが既に出来上がっている場合、今回の構成だと OpenTelemetry の SDK を噛ませる必要があるので、導入コストが高いかもしれない

これらを検討しつつ、アプリケーションを開発していく場合は導入を検討してみてはいかがでしょうか？

# 参考
## Grafana関連
- 実際にリポジトリの example を参考にしました
  - [tempo](https://github.com/grafana/tempo/tree/7421936baaf40a1e94c45a23abbfd5960d1436ab/example/docker-compose/otel-collector)
  - [mimir-monolithic-mode](https://github.com/grafana/mimir/tree/d16f21ce30356d53f6882e71cc05f043266ff01a/development/mimir-monolithic-mode)
  - [loki](https://github.com/grafana/loki/tree/0780456662b67edde69004cf4ee3873c23d5094b/examples/getting-started)

## OpenTelemetry関連
- アプリ内でのSDKの利用
    - https://opentelemetry.io/docs/languages/go/getting-started/
    - https://opentelemetry.io/docs/languages/go/exporters/
- Collector
    - https://opentelemetry.io/docs/collector/installation/
    - https://grafana.com/docs/loki/latest/send-data/otel/