實戰:如何編寫一個 OpenTelemetry Extensions

crossoverJie發表於2024-04-16

前言

前段時間我們從 SkyWalking 切換到了 OpenTelemetry ,與此同時之前使用 SkyWalking 編寫的外掛也得轉移到 OpenTelemetry 體系下。

我也寫了相關介紹文章:
實戰:如何優雅的從 SkyWalking 切換到 OpenTelemetry

好在 OpenTelemetry 社群也提供了 Extensions 的擴充套件開發,我們可以不用去修改社群發行版:opentelemetry-javaagent.jar 的原始碼也可以擴充套件其中的能力。

比如可以:

  • 修改一些 trace,某些 span 不想記錄等。
  • 新增 metrics

這次我準備編寫的外掛也是和 metrics 有關的,因為 pulsar 的 Java sdk 中並沒有暴露客戶端的一些監控指標,所以我需要在外掛中攔截到一些關鍵函式,然後執行暴露出指標。

截止到本文編寫的時候, Pulsar 社群也已經將 Java-client 整合了 OpenTelemetry,後續正式發版後我這個外掛也可以光榮退休了。


由於 OpenTelemetry 社群還處於高速發展階段,我在中文社群沒有找到類似的參考文章(甚至英文社群也沒有,只有一些 example 程式碼,或者是隻有去社群成熟外掛裡去參考程式碼)

其中也踩了不少坑,所以覺得非常有必要分享出來幫助大家減少遇到同類問題的機會。

開發流程

OpenTelemetry extension 的寫法其實和 skywalking 相似,都是用的 bytebuddy這個位元組碼增強庫,只是在一些 API 上有一些區別。

建立專案

首先需要建立一個 Java 專案,這裡我直接參考了官方的示例,使用了 gradle 進行管理(理論上 maven 也是可以的,只是要找到在 gradle 使用的 maven 外掛)。

這裡貼一下簡化版的 build.gradle 檔案:

plugins {
    id 'java'
    id "com.github.johnrengelman.shadow" version "8.1.1"
    id "com.diffplug.spotless" version "6.24.0"
}

group = 'com.xx.otel.extensions'
version = '1.0.0'

ext {
    versions = [
            // this line is managed by .github/scripts/update-sdk-version.sh
            opentelemetrySdk           : "1.34.1",

            // these lines are managed by .github/scripts/update-version.sh
            opentelemetryJavaagent     : "2.1.0-SNAPSHOT",
            opentelemetryJavaagentAlpha: "2.1.0-alpha-SNAPSHOT",

            junit                      : "5.10.1"
    ]

    deps = [
    // 自動生成服務發現 service 檔案
            autoservice: dependencies.create(group: 'com.google.auto.service', name: 'auto-service', version: '1.1.1')
    ]
}

repositories {
    mavenLocal()
    maven { url "https://maven.aliyun.com/repository/public" }
    mavenCentral()
}

configurations {
    otel
}


dependencies {

    implementation(platform("io.opentelemetry:opentelemetry-bom:${versions.opentelemetrySdk}"))

    /*
    Interfaces and SPIs that we implement. We use `compileOnly` dependency because during
    runtime all necessary classes are provided by javaagent itself.
     */
    compileOnly 'io.opentelemetry:opentelemetry-sdk-extension-autoconfigure-spi:1.34.1'
    compileOnly 'io.opentelemetry.instrumentation:opentelemetry-instrumentation-api:1.32.0'
    compileOnly 'io.opentelemetry.javaagent:opentelemetry-javaagent-extension-api:1.32.0-alpha'

    //Provides @AutoService annotation that makes registration of our SPI implementations much easier
    compileOnly deps.autoservice
    annotationProcessor deps.autoservice

    // https://mvnrepository.com/artifact/org.apache.pulsar/pulsar-client
    compileOnly 'org.apache.pulsar:pulsar-client:2.8.0'

}

test {
    useJUnitPlatform()
}

然後便是要建立 javaagent 的一個核心類:

@AutoService(InstrumentationModule.class)  
public class PulsarInstrumentationModule extends InstrumentationModule {
    public PulsarInstrumentationModule() {
        super("pulsar-client-metrics", "pulsar-client-metrics-2.8.0");
    }	
}

在這個類中定義我們外掛的名稱,同時使用 @AutoService 註解可以在打包的時候幫我們在 META-INF/services/目錄下生成 SPI 服務發現的檔案:

這是一個 Google 的外掛,本質是外掛是使用 SPI 的方式進行開發的。

關於 SPI 以前也寫過一篇文章,不熟的朋友可以用作參考:

  • Java SPI 的原理與應用

建立 Instrumentation

之後就需要建立自己的 Instrumentation,這裡可以把它理解為自己的攔截器,需要配置對哪個類的哪個函式進行攔截:

public class ProducerCreateImplInstrumentation implements TypeInstrumentation {

    @Override
    public ElementMatcher<TypeDescription> typeMatcher() {
        return named("org.apache.pulsar.client.impl.ProducerBuilderImpl");
    }
    @Override
    public void transform(TypeTransformer transformer) {
        transformer.applyAdviceToMethod(
                isMethod()
                        .and(named("createAsync")),
                ProducerCreateImplInstrumentation.class.getName() + "$ProducerCreateImplConstructorAdvice");
    }

比如這就是對 ProducerBuilderImpl 類的 createAsync 建立函式進行攔截,攔截之後的邏輯寫在了 ProducerCreateImplConstructorAdvice 類中。

值得注意的是對一些繼承和實現類的攔截方式是不相同的:

@Override  
public ElementMatcher<TypeDescription> typeMatcher() {  
    return extendsClass(named(ENHANCE_CLASS));  
    // return implementsInterface(named(ENHANCE_CLASS));
}

從這兩個函式名稱就能看出,分別是針對繼承和實現類進行攔截的。

這裡的 API 比 SkyWalking 的更易讀一些。

之後需要把我們自定義的 Instrumentation 註冊到剛才的 PulsarInstrumentationModule 類中:

    @Override
    public List<TypeInstrumentation> typeInstrumentations() {
        return Arrays.asList(
                new ProducerCreateImplInstrumentation(),
                new ProducerCloseImplInstrumentation(),
                );
    }

有多個的話也都得進行註冊。

編寫切面程式碼

之後便是編寫我們自定義的切面邏輯了,也就是剛才自定義的 ProducerCreateImplConstructorAdvice 類:

    public static class ProducerCreateImplConstructorAdvice {

        @Advice.OnMethodEnter(suppress = Throwable.class)
        public static void onEnter() {
            // inert your code
            MetricsRegistration.registerProducer();
        }

        @Advice.OnMethodExit(suppress = Throwable.class)
        public static void after(
                @Advice.Return CompletableFuture<Producer> completableFuture) {
            try {
                Producer producer = completableFuture.get();
                CollectionHelper.PRODUCER_COLLECTION.addObject(producer);
            } catch (Throwable e) {
                System.err.println(e.getMessage());
            }
        }
    }

可以看得出來其實就是兩個核心的註解:

  • @Advice.OnMethodEnter 切面函式呼叫之前
  • @Advice.OnMethodExit 切面函式呼叫之後

還可以在 @Advice.OnMethodExit的函式中使用 @Advice.Return獲得函式呼叫的返回值。

當然也可以使用 @Advice.This 來獲取切面的呼叫物件。

編寫自定義 metrics

因為我這個外掛的主要目的是暴露一些自定義的 metrics,所以需要使用到 io.opentelemetry.api.metrics 這個包:

這裡以 Producer 生產者為例,整體流程如下:

  • 建立生產者的時候將生產者物件儲存起來
  • OpenTelemetry 框架會每隔一段時間回撥一個自定義的函式
  • 在這個函式中遍歷所有的 producer 獲取它的監控指標,然後暴露出去。

註冊函式:

public static void registerObservers() {  
    Meter meter = MetricsRegistration.getMeter();  
  
    meter.gaugeBuilder("pulsar_producer_num_msg_send")  
            .setDescription("The number of messages published in the last interval")  
            .ofLongs()  
            .buildWithCallback(  
                    r -> recordProducerMetrics(r, ProducerStats::getNumMsgsSent));

private static void recordProducerMetrics(ObservableLongMeasurement observableLongMeasurement, Function<ProducerStats, Long> getter) {  
    for (Producer producer : CollectionHelper.PRODUCER_COLLECTION.list()) {  
        ProducerStats stats = producer.getStats();  
        String topic = producer.getTopic();  
        if (topic.endsWith(RetryMessageUtil.RETRY_GROUP_TOPIC_SUFFIX)) {  
            continue;  
        }        observableLongMeasurement.record(getter.apply(stats),  
                Attributes.of(PRODUCER_NAME, producer.getProducerName(), TOPIC, topic));  
    }}

回撥函式,在這個函式中遍歷所有的生產者,然後讀取它的監控指標。

這樣就完成了一個自定義指標的暴露,使用的時候只需要載入這個外掛即可:

java -javaagent:opentelemetry-javaagent.jar \
     -Dotel.javaagent.extensions=ext.jar
     -jar myapp.jar

-Dotel.javaagent.extensions=/extensions
當然也可以指定一個目錄,該目錄下所有的 jar 都會被作為 extensions 被加入進來。

打包

使用 ./gradlew build 打包,之後可以在build/libs/目錄下找到生成物。

當然也可以將 extension 直接打包到 opentelemetry-javaagent.jar中,這樣就可以不用指定 -Dotel.javaagent.extensions引數了。

具體可以在 gradle 中加入以下 task:

task extendedAgent(type: Jar) {
  dependsOn(configurations.otel)
  archiveFileName = "opentelemetry-javaagent.jar"
  from zipTree(configurations.otel.singleFile)
  from(tasks.shadowJar.archiveFile) {
    into "extensions"
  }
  //Preserve MANIFEST.MF file from the upstream javaagent
  doFirst {
    manifest.from(
      zipTree(configurations.otel.singleFile).matching {
        include 'META-INF/MANIFEST.MF'
      }.singleFile
    )
  }
}

具體可以參考這裡的配置:
https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/examples/extension/build.gradle#L125

踩坑

看起來這個開發過程挺簡單的,但其中的坑還是不少。

NoClassDefFoundError

首先第一個就是我在除錯過程中出現 NoClassDefFoundError 的異常。

但我把打包好的 extension 解壓後明明是可以看到這個類的。

排查一段時間後沒啥頭緒,我就從頭仔細閱讀了開發文件:

發現我們需要重寫 getAdditionalHelperClassNames函式,用於將我們外部的一些工具類加入到應用的 class loader 中,不然在應用在執行的時候就會報 NoClassDefFoundError 的錯誤。

因為是位元組碼增強的關係,所以很多日常開發覺得很常見的地方都不行了,比如:

  • 如果切面類是一個內部類的時候,必須使用靜態函式
  • 只能包含靜態函式
  • 不能包含任何欄位,常量。
  • 不能使用任何外部類,如果要使用就得使用 getAdditionalHelperClassNames 額外加入到 class loader 中(這一條就是我遇到的問題)
  • 所有的函式必須使用 @Advice 註解

以上的內容其實在文件中都有寫:

所以還是得仔細閱讀文件。

缺少異常日誌

其實上述的異常剛開始都沒有列印出來,只有一個現象就是程式沒有正常執行。

因為沒有日誌也不知道如何排查,也懷疑是不是執行過程中報錯了,所以就嘗試把@Advice 註解的函式全部 try catch ,果然列印了上述的異常日誌。

之後我注意到了註解的這個引數,原來在預設情況下是不會列印任何日誌的,需要手動開啟。

比如這樣:@Advice.OnMethodExit(suppress = Throwable.class)

除錯日誌

最後就是除錯功能了,因為我這個外掛的是把指標傳送到 OpenTelemetry-collector ,再由它發往 VictoriaMetrics/Prometheus;由於整個鏈路比較長,我想看到最終生成的指標是否正常的干擾條件太多了。

好在 OpenTelemetry 提供了多種 metrics.exporter 的輸出方式:

  • -Dotel.metrics.exporter=otlp (default),預設透過 otlp 協議輸出到 collector 中。
  • -Dotel.metrics.exporter=logging,以 stdout 的方式輸出到控制檯,主要用於除錯
  • -Dotel.metrics.exporter=logging-otlp
  • -Dotel.metrics.exporter=prometheus,以 Prometheus 的方式輸出,還可以配置埠,這樣也可以讓 Prometheus 進行遠端採集,同樣的也可以在本地除錯。

採用哪種方式可以根據環境情況自行選擇。

Opentelemetry-operator 配置 extension

最近在使用 opentelemetry-operator注入 agent 的時候發現 operator 目前並不支援配置 extension,所以在社群也提交了一個草案,下週會嘗試提交一個 PR 來新增這個特性。

這個需求我在 issue 列表中找到了好幾個,時間也挺久遠了,不太確定為什麼社群還為實現。

目前 operator 只支援在自定義映象中配置 javaagent.jar,無法配置 extension:

這個原理在之前的文章中有提到。

apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: my-instrumentation
spec:
  java:
    image: your-customized-auto-instrumentation-image:java

我的目的是可以在自定義映象中把 extension 也複製進去,類似於這樣:

FROM busybox

ADD open-telemetry/opentelemetry-javaagent.jar /javaagent.jar

# Copy extensions to specify a path.
ADD open-telemetry/ext-1.0.0.jar /ext-1.0.0.jar

RUN chmod -R go+r /javaagent.jar
RUN chmod -R go+r /ext-1.0.0.jar

然後在 CRD 中配置這個 extension 的路徑:

apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: my-instrumentation
spec:
  java:
    image: custom-image:1.0.0
    extensions: /ext-1.0.0.jar
    env:
    # If extension.jar already exists in the container, you can only specify a specific path with this environment variable.
      - name: OTEL_EXTENSIONS_DIR
        value: /custom-dir

這樣 operator 在拿到 extension 的路徑時,就可以在環境變數中加入 -Dotel.javaagent.extensions=${java.extensions} 引數,從而實現自定義 extension 的目的。

總結

整個過程其實並不複雜,只是由於目前用的人還不算多,所以也很少有人寫教程或者文章,相信用不了多久就會慢慢普及。

這裡有一些官方的 example可以參考。

參考連結:

  • https://github.com/apache/pulsar/pull/22178
  • https://opentelemetry.io/docs/languages/java/automatic/extensions/
  • https://github.com/open-telemetry/opentelemetry-java-instrumentation/tree/main/examples/extension#extensions-examples
  • https://github.com/open-telemetry/opentelemetry-operator/issues/1758#issuecomment-1982159356

相關文章