OpenTelemetry系列 (五)| OpenTelemetry Java Instrumentation二次开发指南

4,150 阅读6分钟

前言

我们上一章介绍了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

compile.png

新建组件

为了便于管理,我们后续的所有功能示例都将包含在一个组件中,而不会进行多组件的分割。

在进行所有的开发之前我们需要新建一个组件:

  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目录下构建我们的项目结构,大致如下:

content.png

至此我们开发的准备工作已经完成,接下来就是愉快的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执行这个类,输出:

noagentresult.png 然后我们引入Agent,再执行,输出:

agentresult.png

我们明显可以看到我们的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接口来让我们自己实现。大致流程如下:

extractandinject.png

也就是说实际上我们需要处理的是上下游传递数据的方式,以及如何进行数据的解析组装。

下面是一个简单实现的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;
  }
}

效果展示

feign1.png

feign2.png

至此,我们就简单的实现了一个可用的调用链插件,实际上纵观整体源码中自带的的插件也不过是这个例子的完善版本,基本原理都是万变不离其宗。

其他的自定义扩展能力

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中提供了一系列的方法来帮助用户自定义想要的配置内容:

config.png

自定义调用链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的处理都是可以展开细谈的。所以后续我可能会视情况更新一些相关的文章,但是不会再放在这个系列中了,敬请期待。