Open16

【Java(Kotlin)編】OTel のドキュメントを読む

柾樹柾樹

Spring Boot starter

API&SDKを順番に読み進めてもわかりづらいので、Spring Boot のゼロコード計装をさきにやる。

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/

柾樹柾樹

Getting started

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/getting-started/

以下を追加することで、自動的に OTel の計装がされていた

dependencies {
	// otel まわりで必要なパッケージ
	implementation(platform(SpringBootPlugin.BOM_COORDINATES))
	implementation(platform("io.opentelemetry:opentelemetry-bom:1.44.0"))
	implementation(platform("io.opentelemetry.instrumentation:opentelemetry-instrumentation-bom:2.10.0"))
	implementation("io.opentelemetry.instrumentation:opentelemetry-spring-boot-starter")
}

src/main/kotlin/com/example/opentelemetry_zero_code_instrumentation_practice/Controller.kt
@RestController
class Controller {
    @GetMapping("/ping")
    fun ping(): String {
        return "pong"
    }
}

柾樹柾樹

Extending instrumentations with the API

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/api/

Span

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/api/#span

span の切り方。

package com.example.opentelemetry_zero_code_instrumentation_practice

import io.opentelemetry.api.OpenTelemetry
import io.opentelemetry.api.trace.Tracer
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class Controller(openTelemetry: OpenTelemetry) {
    private val tracer: Tracer = openTelemetry.getTracer("application")

    @GetMapping("/ping")
    fun ping(): String {
        this.pingSpan()
        return "pong"
    }

    fun pingSpan() {
        val span = tracer.spanBuilder("pingSpan").startSpan()
        span.end()
        return
    }
}

Meter

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/api/#meter

Java API & SDK で実装するためスキップ

柾樹柾樹

SDK configuration

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/sdk-configuration/

General configuration

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/sdk-configuration/#general-configuration

build.gradle.kts
dependencies {
	// 中略

	// 用途
	// Propagator Distribution(tracecontext、b3)に必要
	// - https://opentelemetry.io/docs/specs/otel/context/api-propagators/#propagators-distribution
	// 変更点
	// - application.yaml(application.properties)に otel.propagators=tracecontext,b3 を追加するとき
	implementation("io.opentelemetry:opentelemetry-extension-trace-propagators")
}

src/main/resources/application.yaml
spring:
  application:
    name: opentelemetry-zero-code-instrumentation-practice
otel:
  propagators:
    - tracecontext
    - b3

Overriding Resource Attributes

Resource Attribute の上書き。スキップ。

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/sdk-configuration/#overriding-resource-attributes

Disable the OpenTelemetry Starter

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/sdk-configuration/#disable-the-opentelemetry-starter

OTEL の SDK を無効化する。これを設定するとシグナルが送信されなくなる。

Programmatic configuration

Exclude actuator endpoints from tracing

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/sdk-configuration/#exclude-actuator-endpoints-from-tracing

Sampler の設定。これを設定すると、トレースがサンプリングされなくなる。
ドキュメントのサンプルでは、actuator を drop する。

build.gradle.kts
dependencies {
   // 中略

	// 名前
	// - opentelemetry-samplers
	// Maven Repository の URL
	// - https://mvnrepository.com/artifact/io.opentelemetry.contrib/opentelemetry-samplers
	// 用途
	// - サンプリングのカスタマイズに必要
	// - このリポジトリでは、actuator の drop に使用する
	implementation("io.opentelemetry.contrib:opentelemetry-samplers:1.33.0-alpha")
	implementation("org.springframework.boot:spring-boot-starter-actuator")
}

src/main/kotlin/com/example/opentelemetry_zero_code_instrumentation_practice/FilterPaths.kt
package com.example.opentelemetry_zero_code_instrumentation_practice

import io.opentelemetry.api.trace.SpanKind
import io.opentelemetry.contrib.sampler.RuleBasedRoutingSampler
import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider
import io.opentelemetry.semconv.UrlAttributes
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class FilterPaths {

    // actuator パスをフィルタリングするカスタマイズ
    @Bean
    fun otelCustomizer(): AutoConfigurationCustomizerProvider {
        return AutoConfigurationCustomizerProvider { p ->
            p.addSamplerCustomizer { fallback, config ->
                RuleBasedRoutingSampler.builder(SpanKind.SERVER, fallback)
                    .drop(UrlAttributes.URL_PATH, "^/actuator")
                    .build()
            }
        }
    }
}

たとえば、↓のような actuator が表示されなくなる。

Configure the exporter programmatically

OTLP exporter に設定を追加する。
今回の例だと、認証用の設定を追加する。
戦術の otelCustomizer を使い回すと起動しなくなるので注意。

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/sdk-configuration/#configure-the-exporter-programmatically

package com.example.opentelemetry_zero_code_instrumentation_practice

import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter
import io.opentelemetry.sdk.autoconfigure.spi.AutoConfigurationCustomizerProvider
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class CustomAuth {
    @Bean
    fun otelCustomizer(): AutoConfigurationCustomizerProvider {
        return AutoConfigurationCustomizerProvider { p ->
            p.addSpanExporterCustomizer { exporter, _ ->
                if (exporter is OtlpHttpSpanExporter) {
                    exporter.toBuilder()
                        .setHeaders { headers() }
                        .build()
                } else {
                    exporter
                }
            }
        }
    }

    private fun headers(): Map<String, String> {
        return mapOf("Authorization" to "Bearer ${refreshToken()}")
    }

    private fun refreshToken(): String {
        // e.g. read the token from a kubernetes secret
        return "token"
    }
}

Resource Providers

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/sdk-configuration/#resource-providers

OpenTelemetry Starter を利用すると、以下の Resource が自動的に付与される。

  • Distribution Resource Provider
    • telemetry.distro.name
    • telemetry.distro.version

下記の項目は、所定の箇所に設定(application.yaml、build.gradle.kts、etc...)することで、反映される。

  • Spring Resource Provider
    • service.name
    • service.version

application.yaml の設定例

application.yaml
// 略
otel:
  // 略
  resource:
    attributes:
      deployment.environment: dev
      service:
        name: zero-code-instrumentation-practice
        version: 0.0.1

画像は反映後の例。

Service name

Resource の Service の反映の優先順位について記載。

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/sdk-configuration/#service-name

柾樹柾樹

Annotations

https://opentelemetry.io/ja/docs/zero-code/java/spring-boot-starter/annotations/

WithSpan アノテーションを利用すると、AOP で Span を生成できる。

src/main/kotlin/com/example/opentelemetry_zero_code_instrumentation_practice/TracedClass.kt
package com.example.opentelemetry_zero_code_instrumentation_practice

import io.opentelemetry.api.trace.Span
import io.opentelemetry.api.trace.SpanKind
import io.opentelemetry.instrumentation.annotations.SpanAttribute
import io.opentelemetry.instrumentation.annotations.WithSpan
import org.springframework.stereotype.Component

@Component
class TracedClass {

    @WithSpan
    fun tracedMethod() {}

    @WithSpan(value = "TracedClass span name")
    fun tracedMethodWithSpanName() {
        val currentSpan = Span.current()
        currentSpan.addEvent("ADD EVENT TO tracedMethodWithName SPAN");
        currentSpan.setAttribute("isTestAttribute", true);
    }

    @WithSpan(kind = SpanKind.CLIENT)
    fun tracedMethodWithoutAnnotation() {}

    @WithSpan
    fun tracedMethodWithAttribute(@SpanAttribute("attributeName") parameter: String) {}
}

build.gradle.kts
dependencies {
    // 中略

	// 名前
	// - spring-boot-starter-aop
	// Maven Repository の URL
	// - https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-aop
	// 用途
	// - アノテーションを利用したトレースに必要
	// - AOP であるため、アノテーションを付与しただけでは動作せず、Constructor Injection などでインスタンス化する必要がある
	implementation("org.springframework.boot:spring-boot-starter-aop")
}

ただし、AOP で動作するため、DI しなければ利用できないことに注意。

src/main/kotlin/com/example/opentelemetry_zero_code_instrumentation_practice/Controller.kt
package com.example.opentelemetry_zero_code_instrumentation_practice

import io.opentelemetry.api.OpenTelemetry
import io.opentelemetry.api.trace.Tracer
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class Controller(
    openTelemetry: OpenTelemetry,
    private val tracedClass: TracedClass
) {
    private val tracer: Tracer = openTelemetry.getTracer("application")

    @GetMapping("/ping")
    fun ping(): String {
        this.pingSpan()
        return "pong"
    }

    fun pingSpan() {
        val span = tracer.spanBuilder("pingSpan").startSpan()
        span.end()
        return
    }

    @GetMapping("/ping2")
    fun ping2(): String {
        tracedClass.tracedMethod()
        tracedClass.tracedMethodWithSpanName()
        tracedClass.tracedMethodWithoutAnnotation()
        tracedClass.tracedMethodWithAttribute("attributeValue")
        return "pong2"
    }
}

柾樹柾樹

APIによるテレメトリーの記録

https://opentelemetry.io/ja/docs/languages/java/api/

柾樹柾樹

Context API

https://opentelemetry.io/ja/docs/languages/java/api/#context-api

Context

Context:アプリケーション全体で暗黙的または明示的に伝搬される不変のキー値ペアのバンドル

アプリケーション全体で、保持したいキーバリューで使う。

https://opentelemetry.io/ja/docs/languages/java/api/#context

以下は使い方一覧。

src/main/kotlin/com/example/otel_kotlin_api_usage_with_spring_starter/ContextUsage.kt
package com.example.otel_kotlin_api_usage_with_spring_starter

import io.opentelemetry.context.Context
import io.opentelemetry.context.ContextKey
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit

class ContextUsage {
    companion object {
        @JvmStatic
        @Throws(Exception::class)
        fun contextUsage() {
            // 例のコンテキストキーを定義
            val exampleContextKey: ContextKey<String> = ContextKey.named("example-context-key")

            // コンテキストは追加するまでキーを含まない
            // Context.current() は現在のコンテキストにアクセス
            // 出力 => current context value: null
            println("current context value: " + Context.current().get(exampleContextKey))

            // コンテキストにエントリを追加
            val context = Context.current().with(exampleContextKey, "value")

            // ローカルコンテキスト変数には追加された値が含まれる
            // 出力 => context value: value
            println("context value: " + context.get(exampleContextKey))
            // 現在のコンテキストはまだ値を含まない
            // 出力 => current context value: null
            println("current context value: " + Context.current().get(exampleContextKey))

            // context.makeCurrent() を呼び出すと、スコープが閉じられるまで
            // Context.current() がコンテキストに設定され、その後 Context.current() は
            // context.makeCurrent() が呼び出される前の状態に復元される。
            // 結果として得られる Scope は AutoCloseable を実装し、通常は
            // try-with-resources ブロックで使用される。Scope.close() の呼び出しに失敗すると
            // エラーとなり、メモリリークやその他の問題を引き起こす可能性がある。
            context.makeCurrent().use { scope ->
                // 現在のコンテキストに追加された値が含まれる
                // 出力 => context value: value
                println("context value: " + Context.current().get(exampleContextKey))
            }

            // ローカルコンテキスト変数には追加された値がまだ含まれる
            // 出力 => context value: value
            println("context value: " + context.get(exampleContextKey))
            // 現在のコンテキストにはもう値が含まれない
            // 出力 => current context value: null
            println("current context value: " + Context.current().get(exampleContextKey))

            val executorService = Executors.newSingleThreadExecutor()
            val scheduledExecutorService = Executors.newScheduledThreadPool(1)

            // コンテキストインスタンスはアプリケーションコード内で明示的に渡すことができるが、
            // Context.makeCurrent() を呼び出し、Context.current() を介してアクセスする
            // 暗黙のコンテキストを使用する方が便利である。
            // コンテキストは暗黙のコンテキスト伝播のための多数のユーティリティを提供する。
            // これらのユーティリティは Scheduler、ExecutorService、ScheduledExecutorService、
            // Runnable、Callable、Consumer、Supplier、Function などのユーティリティクラスを
            // ラップし、実行前に Context.makeCurrent() を呼び出すように動作を変更する。
            context.wrap(::callable).call()
            context.wrap(::runnable).run()
            context.wrap(executorService).submit(::runnable)
            context.wrap(scheduledExecutorService).schedule(::runnable, 1, TimeUnit.SECONDS)
            context.wrapConsumer<Any>(::consumer).accept(Any())
            context.wrapConsumer<Any, Any>(::biConsumer).accept(Any(), Any())
            context.wrapFunction<Any, Any>(::function).apply(Any())
            context.wrapSupplier<Any>(::supplier).get()
        }

        /** 例の [java.util.concurrent.Callable]。 */
        private fun callable(): Any {
            return Any()
        }

        /** 例の [Runnable]。 */
        private fun runnable() {}

        /** 例の [java.util.function.Consumer]。 */
        private fun consumer(obj: Any) {}

        /** 例の [java.util.function.BiConsumer]。 */
        private fun biConsumer(object1: Any, object2: Any) {}

        /** 例の [java.util.function.Function]。 */
        private fun function(obj: Any): Any {
            return obj
        }

        /** 例の [java.util.function.Supplier]。 */
        private fun supplier(): Any {
            return Any()
        }
    }
}

関数を区切った方がわかりやすいので、区切った時の実装は以下。
ポイントは Context.current().get(keyName)context.get(keyName) で値を共有できるか。
前者は、context.makeCurrent() の内部ならどこでも呼べるようになる。後者は context を変数として引き回す必要がある。

src/main/kotlin/com/example/otel_kotlin_api_usage_with_spring_starter/Controller.kt
package com.example.otel_kotlin_api_usage_with_spring_starter

import io.opentelemetry.api.GlobalOpenTelemetry
import io.opentelemetry.api.trace.Span
import io.opentelemetry.context.Context
import io.opentelemetry.context.ContextKey
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RestController

@RestController
class Controller {
    private val tracer = GlobalOpenTelemetry.getTracer("otel-kotlin-api-usage")
    private val userIdKey: ContextKey<String> = ContextKey.named("user-id")

    @GetMapping("/ping")
    fun ping(): String {
        return "pong"
    }

    @GetMapping("/context-demo")
    fun contextDemo(): Map<String, String> {
        val span = tracer.spanBuilder("context-demo").startSpan()

        try {
            span.makeCurrent().use { scope ->
                val context = Context.current().with(userIdKey, "user-123")
                // context を共有しないか、makeCurrent の外で呼び出すと null になる
                contextCallInMakeCurrent(userIdKey)

                // context を共有して、内部で makeCurrent を呼び出ときに値が取得できる
                contextStartUsage(context, userIdKey)

                context.makeCurrent().use { contextScope ->
                    // makeCurrent の中であれば current を引数に利用しなくても、Context.current() で値が取得できる
                    contextCallInMakeCurrent(userIdKey)
                    val currentUserId = Context.current().get(userIdKey)
                    Span.current().setAttribute("user.id", currentUserId ?: "unknown")

                    return mapOf(
                        "message" to "Context demonstration",
                        "userId" to (currentUserId ?: "not set"),
                        "traceId" to span.spanContext.traceId
                    )
                }
            }
        } finally {
            span.end()
        }
    }

    /**
     * contextCallInMakeCurrent
     *
     * makeCurrent の中で呼び出すと Context.current() で値が取得できる。
     * makeCurrent の外で呼び出すと Context.current() でnullを取得する
     *
     * @param userIdKey
     */
    fun contextCallInMakeCurrent(userIdKey: ContextKey<String>) {
        val currentUserId = Context.current().get(userIdKey)
        println("userIdKey in contextCallInMakeCurrent is $currentUserId")
    }

    /**
     * contextStartUsage
     *
     * Context.current().get() は makeCurrent の中で呼び出すと値が取得できる。
     * context.get() は makeCurrent の外で呼び出しても値が取得できる。ただし、context を共有(変数の引き回し)をした場合に限る。
     *
     * @param context
     * @param userIdKey
     */
    fun contextStartUsage(context: Context, userIdKey: ContextKey<String>) {
        context.makeCurrent().use { contextScope ->
            contextCallInMakeCurrent(userIdKey)
            val currentUserId = Context.current().get(userIdKey)
            println("currentUserId in contextStartUsage is $currentUserId")
        }
        val currentUserId = context.get(userIdKey)
        println("userIdKey in contextStartUsage is $currentUserId")
    }

    @GetMapping("/context-service-demo")
    fun contextServiceDemo(): String {
        ContextUsage.contextUsage()
        return "context-service-demo"
    }
}

ContextStorage

https://opentelemetry.io/ja/docs/languages/java/api/#contextstorage

Context.current() の内部で利用されている。

ContextStorageは現在のContextを保存および取得するメカニズムです。
デフォルトのContextStorage実装はContextをスレッドローカルに保存します。

https://grpc.github.io/grpc-java/javadoc/io/grpc/Context.Storage.html

public interface Context {

  /** Return the context associated with the current {@link Scope}. */
  static Context current() {
    Context current = ContextStorage.get().current();
    return current != null ? current : root();
  }

// 略
}

ContextPropagators

アウトバンドHTTPリクエストにコンテキストを注入(コンテキストプロぱゲーション)したいときに利用する。

https://opentelemetry.io/ja/docs/languages/java/api/#contextpropagators

下記は http://localhost:8080/resource へのリクエストにコンテキストを注入するサンプルコード。

src/main/kotlin/com/example/otel_kotlin_api_usage_with_spring_starter/InjectContextUsage.kt
package com.example.otel_kotlin_api_usage_with_spring_starter

import io.opentelemetry.api.baggage.propagation.W3CBaggagePropagator
import io.opentelemetry.api.trace.propagation.W3CTraceContextPropagator
import io.opentelemetry.context.Context
import io.opentelemetry.context.propagation.ContextPropagators
import io.opentelemetry.context.propagation.TextMapPropagator
import io.opentelemetry.context.propagation.TextMapSetter
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse

class InjectContextUsage {
    companion object {
        private val TEXT_MAP_SETTER: TextMapSetter<HttpRequest.Builder> = HttpRequestSetter()

        fun injectContextUsage() {
            // w3cトレースコンテキストとw3cバゲージを伝播するContextPropagatorsインスタンスを作成
            val propagators = ContextPropagators.create(
                TextMapPropagator.composite(
                    W3CTraceContextPropagator.getInstance(),
                    W3CBaggagePropagator.getInstance()
                )
            )

            // HttpRequestビルダーを作成
            val httpClient = HttpClient.newBuilder().build()
            val requestBuilder = HttpRequest.newBuilder()
                .uri(URI("http://127.0.0.1:8080/resource"))
                .GET()

            // ContextPropagatorsインスタンスがあるとき、現在のコンテキストをHTTPリクエストキャリアに注入
            propagators.textMapPropagator.inject(Context.current(), requestBuilder, TEXT_MAP_SETTER)

            // 注入されたコンテキストでリクエストを送信
            httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.discarding())
        }
    }

    /** [HttpRequest.Builder]キャリアを持つ[TextMapSetter]。 */
    private class HttpRequestSetter : TextMapSetter<HttpRequest.Builder> {
        override fun set(carrier: HttpRequest.Builder?, key: String, value: String) {
            carrier?.setHeader(key, value)
        }
    }
}

src/main/kotlin/com/example/otel_kotlin_api_usage_with_spring_starter/Controller.kt

    @GetMapping("/inject-context-demo")
    fun injectContextDemo(): Map<String, String> {
        val span = tracer.spanBuilder("inject-context-demo").startSpan()

        try {
            span.makeCurrent().use {
                // バゲージを設定
                val baggage = io.opentelemetry.api.baggage.Baggage.builder()
                    .put("user.id", "user-456")
                    .put("request.id", "req-789")
                    .build()

                // バゲージをコンテキストに追加
                baggage.makeCurrent().use {
                    // InjectContextUsageを呼び出し
                    InjectContextUsage.injectContextUsage()

                    return mapOf(
                        "message" to "Context injection demonstration",
                        "traceId" to span.spanContext.traceId,
                        "userId" to (baggage.getEntryValue("user.id") ?: "not set")
                    )
                }
            }
        } finally {
            span.end()
        }
    }

    @GetMapping("/resource")
    fun resource(): Map<String, String> {
        val currentSpan = Span.current()
        val baggage = io.opentelemetry.api.baggage.Baggage.current()

        // 受け取ったトレース情報とバゲージを表示
        val userId = baggage.getEntryValue("user.id")
        val requestId = baggage.getEntryValue("request.id")

        println("=== /resource endpoint ===")
        println("TraceId: ${currentSpan.spanContext.traceId}")
        println("SpanId: ${currentSpan.spanContext.spanId}")
        println("Baggage - user.id: $userId")
        println("Baggage - request.id: $requestId")

        return mapOf(
            "message" to "Resource endpoint",
            "traceId" to currentSpan.spanContext.traceId,
            "spanId" to currentSpan.spanContext.spanId,
            "userId" to (userId ?: "not received"),
            "requestId" to (requestId ?: "not received")
        )
    }

以下は inject しなかった例。
Spring Boot へのアクセスが 2 つになっており、resource のトレースが区切れている。nginx は外部からリクエストされたときにしか、通らないため 1 つ。

以下は inject した例。
Spring Boot へのアクセスが 1 つになり、resource のトレースがつながっている。

extract パターンもある。
別リポジトリで実装。

https://github.com/Msksgm/otel-kotlin-extract-context-usage