實戰:如何編寫一個 OpenTelemetry Extensions

前言

前段時間我們從 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 以前也寫過一篇文章,不熟的朋友可以用作參考:

創建 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 -&gt; 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可以參考。

參考鏈接:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章