OpenTelemetry系列 (五)| OpenTelemetry Java Instrumentation二次開發指南

前言

我們上一章介紹了OpenTelemetry Java Instrumentation的使用,但是那些都是一些基本的使用,如果有一些自定義的功能,還是需要一些開發工作。本章節將以保姆級教程來介紹一下如何在OpenTelemetry Java Instrumentation上進行二次開發(本文中會將OpenTelemetry Java Instrumentation簡寫成OJI以方便閱讀)。

本文中的相關源碼以及相關的實現均爲目前的OJI的最新main分支版本目前爲1.22.0-SNAPSHOT

開發準備

第一次編譯

OJI使用gradle來進行依賴管理,核心的依賴和倉庫等等信息都包含在根目錄下的settings.gradle.kts中,後續的相關維護也要在其中。並且OJI的開發需要jdk9+的版本,因此需要確認自己的jdk版本是否符合要求。

在確認好前期準備後進入項目,執行gradle assemble來進行打包操作。初次打包可能需要花費超過1個小時,需要耐心等待。除此之外如果遇到依賴拉取失敗問題,則可以在build.gradle.kts文件中添加如下配置以使用其他的倉庫地址,此處使用的是阿里雲倉庫:

allprojects {
  repositories {
    maven {
      setUrl("https://maven.aliyun.com/nexus/content/groups/public/")
    }
    mavenCentral()
    mavenLocal()
    jcenter {
      setUrl("https://jcenter.bintray.com/")
    }
    google()
  }
}

經過一段時間等待,當控制檯輸出如下文字,即表示編譯成功。

BUILD SUCCESSFUL in 12m 25s
2464 actionable tasks: 2212 executed, 232 from cache, 20 up-to-date

A build scan was not published as you have not authenticated with server 'ge.opentelemetry.io'.
For more information, please see https://gradle.com/help/gradle-authenticating-with-gradle-enterprise.

之後我們就可以在javaagent/build/libs目錄下找到最終的agent包opentelemetry-javaagent-{version}.jar

新建組件

爲了便於管理,我們後續的所有功能示例都將包含在一個組件中,而不會進行多組件的分割。

在進行所有的開發之前我們需要新建一個組件:

  1. instrumentation目錄下新建一個目錄作爲組件的目錄,我此處將其命名爲bjwzds
  2. 在此目錄中新建javaagent目錄,並在此目錄下創建build.gradle.kts文件,文件內容如下:
plugins {
  id("otel.javaagent-instrumentation")
}

dependencies {

}
  1. 在全局的settings.gradle.kts中添加hideFromDependabot(":instrumentation:bjwzds:javaagent")或者是include(":instrumentation:bjwzds:javaagent")來引入我們新增的模塊。
  2. 開始在javaagent目錄下構建我們的項目結構,大致如下:

至此我們開發的準備工作已經完成,接下來就是愉快的coding環節了!

自定義Instrumentation

需要注意的是在早期版本中可以自己完全創建項目然後以外部插件的形式來注入,但是在後續版本中這種方式被廢棄,因此後續的開發都是在clone下來的OJI項目中進行開發。

雖然OJI已經提供了非常多的Instrumentation類庫,許許多多知名的開源項目都在其中,但是總是可能會有一些需要自己來處理的內容,比如一些商用庫,比如一些公司自制的二方或者三方依賴。這些都不可能找到現成的實現,那麼就只能自己來了!

OJI提供了完善的Instrumentation擴展能力,大家可以自行定義自己需要的Instrumentation

簡單例子

要創建一個自定義的Instrumentation,最基礎的是要先創建一個繼承InstrumentationModule的類:

@AutoService(InstrumentationModule.class)
public class BjwzdsInstrumentationModule extends InstrumentationModule {
  public BjwzdsInstrumentationModule() {
    // 此處定義的是組件的名稱,以及組件的別名,會在配置組件的開關時使用
    super("bjwzds", "bjwzds-1.0");
  }

  @Override
  public List<TypeInstrumentation> typeInstrumentations() {
    // 組件內包含的TypeInstrumentation,是一個list
    return Collections.singletonList(new BjwzdsInstrumentation());
  }
}

在有了InstrumentationModule的類後,只需要再創建一個TypeInstrumentation的實現類,我們的一個最簡單的Instrumentation就已經完成了。如下是一個TypeInstrumentation的實現的例子:

public class BjwzdsInstrumentation implements TypeInstrumentation {
  @Override
  public ElementMatcher<TypeDescription> typeMatcher() {
    return named("org.example.bjwzds.AgentTest");
  }

  @Override
  public void transform(TypeTransformer transformer) {
    transformer.applyAdviceToMethod(
        isMethod()
            .and(isPublic())
            .and(named("test")),
        this.getClass().getName() + "$BjwzdsAdvice");
  }

  @SuppressWarnings("unused")
  public static class BjwzdsAdvice {

    @Advice.OnMethodEnter(suppress = Throwable.class)
    public static void methodEnter() {
      System.out.println("enter method");
    }

    @Advice.OnMethodExit(suppress = Throwable.class)
    public static void methodExit() {
      System.out.println("exit method");
    }
  }
}

這個例子很簡單,作用是在org.example.bjwzds.AgentTest.test方法的執行前和執行後分別輸出日誌。

  1. typeMatcher中定義的事需要進行切面的類,這個例子中指定了名稱,全限定名叫做org.example.bjwzds.AgentTest
  2. transform中定義切面的方法,以及方法綁定的執行類。在這個例子中切面的方法爲是public方法且名字是test的方法,綁定的類是當前類的內部類BjwzdsAdvice
  3. 綁定的執行類BjwzdsAdvice定義了@Advice.OnMethodEnter@Advice.OnMethodExit這兩個註解分別用來定義進入方法和離開方法。因此這個類會在進入方法時執行System.out.println("enter method");並在離開時執行System.out.println("exit method");

我們將修改完的代碼編譯生成新的agent,然後在我們的demo項目中進行測試。

DEMO項目很簡單,只有一個類文件:

package org.example.bjwzds;

public class AgentTest {
    public void test() {
        System.out.println("This is a test function");
    }

    public static void main(String[] args) {
        AgentTest agentTest = new AgentTest();

        agentTest.test();
    }
}

我們先不引入agent執行這個類,輸出: 然後我們引入Agent,再執行,輸出:

我們明顯可以看到我們的Agent已經生效了,並且如我們設計的一樣,在test執行前後分別輸出了日誌。

擴展能力

上面的例子是一個簡單的Instrumentation例子,但是實際上在具體的使用中僅僅如此是不夠的,接下來我們來介紹一些Instrumentation更廣闊的能力。

接下來爲了讓大家更加了解Instrumentation的實現,我們來一起實現一個真正的調用鏈的Instrumentation樣例。

首先需要說明的是調用鏈組件的實現形式和我們上述的例子並無二致,兩者間的差距是綁定的執行方法中邏輯的區別。在正式開始前我們先來看一個java-http-client插件的例子:

@Advice.OnMethodEnter(suppress = Throwable.class)
    public static void methodEnter(
        @Advice.Argument(value = 0) HttpRequest httpRequest,
        @Advice.Local("otelContext") Context context,
        @Advice.Local("otelScope") Scope scope) {
      Context parentContext = currentContext();
      if (!instrumenter().shouldStart(parentContext, httpRequest)) {
        return;
      }

      context = instrumenter().start(parentContext, httpRequest);
      scope = context.makeCurrent();
    }

    @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class)
    public static void methodExit(
        @Advice.Argument(0) HttpRequest httpRequest,
        @Advice.Return HttpResponse<?> httpResponse,
        @Advice.Thrown Throwable throwable,
        @Advice.Local("otelContext") Context context,
        @Advice.Local("otelScope") Scope scope) {
      if (scope == null) {
        return;
      }

      scope.close();
      instrumenter().end(context, httpRequest, httpResponse, throwable);
    }

這是一個典型的調用鏈插件的實現邏輯,代碼中的shouldStartstartend都是OJI提供的API,用來幫助生成TraceSpan,以及處理一些必要的邏輯。上述代碼中的instrumenter()是自己實現的代碼,但是其也不過是調用API來構建一個特定類型的Instrumenter

按照上面的說法,那我們豈不是隻要CtrlC,CtrlV就能夠簡單的創建一個調用鏈組件了呢?

答案是“是,也不完全是”。大致的邏輯上各個組件並無二致,甚至如果是同樣類型的組件,如apache-httpclientokhttp這種,那麼絕大部分的代碼都能夠複用,但是這裏有一個巨大的區別。

在之前的文章中我們曾經提到過調用鏈想要串聯在一起需要將TraceId一級一級的透傳下去,但是不同的組件透傳的方式是不一樣的,是的,這個區別就是每個組件需要根據自己的特點來實現TraceId的透傳。

聽上去似乎很複雜,但是好在OpenTelemetry在這方面也做好了準備,它將這個過程進行了抽象並提供了TextMapSetterTextMapGetter接口來讓我們自己實現。大致流程如下:

也就是說實際上我們需要處理的是上下游傳遞數據的方式,以及如何進行數據的解析組裝。

下面是一個簡單實現的OpenFeign的調用鏈組件(其實feign的組件在社區在就有人提出,但是歷經多個版本pr仍未被合併,這裏的組件是我自己實現的一個極其簡易版本,僅用於demo展示):

FeignInstrumentation:

public class FeignInstrumentation implements TypeInstrumentation {
  @Override
  public ElementMatcher<TypeDescription> typeMatcher() {
    return named("feign.SynchronousMethodHandler");
  }

  @Override
  public void transform(TypeTransformer transformer) {
    transformer.applyAdviceToMethod(
        named("executeAndDecode"), this.getClass().getName() + "$RequestAdvice");
  }

  @SuppressWarnings("unused")
  public static class RequestAdvice {

    @Advice.OnMethodEnter(suppress = Throwable.class)
    public static void addRequestEnter(
        @Advice.Argument(0) RequestTemplate template,
        @Advice.Local("otelContext") Context context,
        @Advice.Local("otelScope") Scope scope) {
      Context parentContext = Java8BytecodeBridge.currentContext();

      if (!instrumenter().shouldStart(parentContext, template)) {
        return;
      }

      context = instrumenter().start(parentContext, template);
      scope = context.makeCurrent();
    }

    @Advice.OnMethodExit(onThrowable = Throwable.class, suppress = Throwable.class)
    public static void addRequestExit(
        @Advice.Argument(0) RequestTemplate template,
        @Advice.Thrown Throwable exception,
        @Advice.Local("otelContext") Context context,
        @Advice.Local("otelScope") Scope scope) {
      if (scope == null) {
        return;
      }

      scope.close();

      instrumenter().end(context, template, null, exception);
    }
  }
}

FeignSingleton核心實例構建類,將設定好的配置類組合在一起生成Instrumenter實例:

public class FeignSingleton {
  private static final String INSTRUMENTATION_NAME = "io.opentelemetry.feign-1.0";
  private static final Instrumenter<RequestTemplate, Void> INSTRUMENTER;

  static {
    HttpClientAttributesGetter<RequestTemplate, Void> httpAttributesGetter =
        new FeignHttpAttributesGetter();

    NetClientAttributesGetter<RequestTemplate, Void> netAttributesGetter =
        new FeignNetAttributesGetter();

    INSTRUMENTER =
        Instrumenter.<RequestTemplate, Void>builder(
                GlobalOpenTelemetry.get(),
                INSTRUMENTATION_NAME,
                HttpSpanNameExtractor.create(httpAttributesGetter))
            .addAttributesExtractor(
                HttpClientAttributesExtractor.builder(httpAttributesGetter, netAttributesGetter).build())
            .buildClientInstrumenter(HttpHeaderSetter.INSTANCE);
  }

  public static Instrumenter<RequestTemplate, Void> instrumenter() {
    return INSTRUMENTER;
  }

  private FeignSingleton() {}
}

HttpHeaderSetter,負責將內存中的數據轉換並存儲到發往下游請求的請求頭:

enum HttpHeaderSetter implements TextMapSetter<RequestTemplate> {
  INSTANCE;

  @Override
  public void set(@Nullable RequestTemplate carrier, String key, String value) {
    if (carrier == null) {
      return;
    }
    carrier.header(key, value);
  }
}

FeignHttpAttributesGetter,span中的http部分數據採集:

public class FeignHttpAttributesGetter implements HttpClientAttributesGetter<RequestTemplate, Void> {
  @Nullable
  @Override
  public String url(RequestTemplate requestTemplate) {
    return requestTemplate.url();
  }

  @Nullable
  @Override
  public String flavor(RequestTemplate requestTemplate, @Nullable Void unused) {
    return "feign";
  }

  @Nullable
  @Override
  public String method(RequestTemplate requestTemplate) {
    return requestTemplate.method();
  }

  @Override
  public List<String> requestHeader(RequestTemplate requestTemplate, String name) {
    return new ArrayList<>();
  }

  @Nullable
  @Override
  public Integer statusCode(RequestTemplate requestTemplate, Void unused,
      @Nullable Throwable error) {
    return 200;
  }

  @Override
  public List<String> responseHeader(RequestTemplate requestTemplate, Void unused, String name) {
    return new ArrayList<>();
  }
}

FeignNetAttributesGetter,span中的net部分數據採集:

public class FeignNetAttributesGetter implements NetClientAttributesGetter<RequestTemplate, Void> {
  @Nullable
  @Override
  public String transport(RequestTemplate requestTemplate, @Nullable Void unused) {
    return "transport";
  }

  @Nullable
  @Override
  public String peerName(RequestTemplate requestTemplate) {
    return "peerName";
  }

  @Nullable
  @Override
  public Integer peerPort(RequestTemplate requestTemplate) {
    return 10000;
  }
}

效果展示

至此,我們就簡單的實現了一個可用的調用鏈插件,實際上縱觀整體源碼中自帶的的插件也不過是這個例子的完善版本,基本原理都是萬變不離其宗。

其他的自定義擴展能力

OJI中除了用戶可以自定義調用鏈的組件,同樣的用戶也可以自定義一些其他的擴展插件能力,在這個篇章會介紹一些比較可能用到的擴展能力。

自定義配置

AutoConfigurationCustomizerProvider是用來自定義用戶需要注入的配置的接口。

一個簡單的例子:

@AutoService(AutoConfigurationCustomizerProvider.class)
public class MineAutoConfigurationCustomizerProvider implements
    AutoConfigurationCustomizerProvider {

  @Override
  public void customize(AutoConfigurationCustomizer autoConfigurationCustomizer) {
    autoConfigurationCustomizer
        .addPropertiesSupplier(this::getDefaultProperties);
  }

  private Map<String, String> getDefaultProperties() {
    Map<String, String> properties = new HashMap<>();
    properties.put("otel.exporter.otlp.endpoint", "http://backend:8080");
    properties.put("otel.exporter.otlp.insecure", "true");
    properties.put("otel.config.max.attrs", "16");
    properties.put("otel.traces.sampler", "demo");
    return properties;
  }
}

在上面的例子中在啓動之時添加了自定義的一些配置。接口的暴露的方法是customize,其參數是AutoConfigurationCustomizer,在AutoConfigurationCustomizer中提供了一系列的方法來幫助用戶自定義想要的配置內容:

自定義調用鏈ID生成規則

在默認的SDK實現中OpenTelemetry使用的是默認的RandomIdGenerator來生成traceIdspanId,但是在真實的使用場景中往往需要自定義自己的規則來制定獨特的traceIdspanId,這個時候就需要使用到IdGenerator了。

簡單實現接口IdGenerator就能夠自定義Id生成規則:

public class MineIdGenerator implements IdGenerator {
  @Override
  public String generateSpanId() {
    return String.valueOf(System.currentTimeMillis());
  }

  @Override
  public String generateTraceId() {
    return String.valueOf(System.currentTimeMillis());
  }
}

之後在AutoConfigurationCustomizer中以如下方式將自己定義的IdGenerator加入進去即可生效:

@Override
  public void customize(AutoConfigurationCustomizer autoConfigurationCustomizer) {
    autoConfigurationCustomizer
        .addTracerProviderCustomizer(this::configureSdkTracerProvider);
  }

  private SdkTracerProviderBuilder configureSdkTracerProvider(
      SdkTracerProviderBuilder tracerProvider, ConfigProperties configProperties) {
    return tracerProvider
        .setIdGenerator(new MineIdGenerator());
  }

自定義透傳

在上文中我們提到過調用鏈的一個核心是調用鏈數據的透傳,透傳就需要藉助到Propagator機制,在OJI中默認使用的是兩個PropagatorTraceparentBaggage

Traceparent用於調用鏈的traceId等調用鏈基礎數據的傳遞 Baggage可以用於自定義的請求頭的傳遞,以固定的請求頭"baggage",其中他的指格式爲K-V結構

按照道理來說這兩個Propagator基本也已經夠用了,但是在一些場景,如全鏈路灰度,全鏈路壓測時,往往需要使用自己獨有的標識,那麼一個自定義的傳遞標識(請求頭)就很有必要了。

想要創建自己的Propagator,那麼就需要先實現接口ConfigurablePropagatorProvider

@AutoService(ConfigurablePropagatorProvider.class)
public class ColorConfigurablePropagatorProvider implements ConfigurablePropagatorProvider {
  @Override
  public TextMapPropagator getPropagator(ConfigProperties config) {
    return new ColorPropagator();
  }

  @Override
  public String getName() {
    return "color";
  }
}

在這個接口中定義了Propagator的名稱與實現。

接下來就是實現類,實現類需要實現TextMapPropagator接口,如下:

public class ColorPropagator implements TextMapPropagator {
  private static final String FIELD = "color";
  private static final ContextKey<String> PROPAGATION_KEY =
      ContextKey.named("propagation.color");

  @Override
  public Collection<String> fields() {
    return Collections.singletonList(FIELD);
  }

  @Override
  public <C> void inject(Context context, @Nullable C carrier, TextMapSetter<C> setter) {
    String v = context.get(PROPAGATION_KEY);

    if (v != null) {
      setter.set(carrier, FIELD, v);
    }
  }

  @Override
  public <C> Context extract(Context context, @Nullable C carrier, TextMapGetter<C> getter) {
    String v = getter.get(carrier, FIELD);
    if (v != null) {
      return context.with(PROPAGATION_KEY, v);
    } else {
      return context;
    }
  }
}

上面實現了一個最簡單的Propagator,這個Propagator的名稱是color,傳播時的header關鍵字也是color,他只做了一件事,就是接收數據時將數據從header中取出存放入內存中,ContextKey"propagation.color",而數據發出時就從內存中取出數據放入header之中。因此上述的實現流程以最簡單的形式構建了一個支持透傳的Propagator

其他

其實OJI還支持其他種類的擴展,但是由於篇幅有限,以及一些擴展方式並不是很常用,因此略過。如果感興趣可以在此處extensions找到官方的更多文檔。

總結

這篇講述OJI的二次開發的一些方式並列出了代碼,我自認爲全網應該不會有比這篇更加詳細的文章來講述這方面的內容。(這都是踩了無數的坑總結出來的!)

至此OpenTelemetry系列的文章暫時就告一段落了,但是這不代表後續相關的內容就完全結束了。其中包含整體觀測體系的架構,調用鏈的存儲方式,metrics的處理都是可以展開細談的。所以後續我可能會視情況更新一些相關的文章,但是不會再放在這個系列中了,敬請期待。

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